A config format for humans
SOML is short for Sindre's Objectively Marvelous Language.
SOML is a config format that is pleasant to write and easy to read. The top level needs no braces, keys need no quotes, line breaks replace commas, and comments go almost anywhere. A value's type comes from its syntax, never from its content, so the type you see is the type you get: '123' is a string and 123 is an int. Dates like 2026-09-19, instants like 2026-09-19T14:00:00Z, and durations like 30s are real values, so nobody has to guess the unit of timeout: 30. Paths and regexes need no escaping, and prose goes in block strings. One canonical form and a shared conformance suite mean every implementation reads your file the same way. JSON has the strictness but not the comfort: quotes, commas, and braces everywhere, and no comments. YAML has the comfort but not the clarity: - list markers, nested indentation, and types guessed from content, so country: no becomes false, and version: 3.10 is a float while version: 3.10.1 is a string. SOML gives you the comfort and the strictness, without the guessing.
Warning
Draft spec. Feedback welcome.
name: 'api-gateway'
port: 8080 # The load balancer expects this port.
timeout: 30s
launch-date: 2027-09-19
windows-path: 'C:\Users\sindre\dev'
description:
'''
Terminates TLS.
Routes requests to internal services.
'''
allowed-origins: [
'https://example.com'
'https://example.org'
]
The same in JSON:
{ "name": "api-gateway", "port": 8080, "timeout": 30, "launch-date": "2027-09-19", "windows-path": "C:\\Users\\sindre\\dev", "description": "Terminates TLS.\nRoutes requests to internal services.", "allowed-origins": [ "https://example.com", "https://example.org" ] }
SOML is a data format for files that people write and read. It is JSON without the noise, and with comments. It keeps JSON's tree of objects and arrays and its strictness, and adds literal strings, block strings, dates, instants, and durations.
Its design rests on one rule: a value's type comes from its syntax, never from its content. '123' is a string and 123 is an int. Nothing is ever retyped by inspecting it.
country: no is an error rather than false, and '10.20' is always a string and 10.20 is always a float (IEEE 754 binary64).3 and 3.0 stay different types.2026-09-19 is a calendar date, 2026-09-19T14:00:00Z is an instant, and 1h30m is a duration, so no reader has to parse a string or guess a unit./*
Everything SOML can express, in one document. A document may also be a top-level array.
*/
# Keys are bare. Dashes are allowed, so no key needs quotes.
name: 'soml'
launch-date: 2027-09-19
released-at: 2026-09-19T14:00:00Z
'the name': 'quotes are the escape hatch, when a key needs spaces'
404: 'and a key may begin with a digit'
# Strings: '...' is literal, "..." is escaped. Use ' by default.
regex: '^\d{4}-\d{2}-\d{2}$'
windows-path: 'C:\Users\sindre\dev'
shell: 'echo "$HOME" | tr -d "\n"'
escaped: "line one\nline two\t\"quoted\"\u{1f600}"
# Block strings are dedented by the closing delimiter's column.
description:
'''
A block string keeps its line breaks.
The leading tab of each line is stripped,
because the closing delimiter sits at one tab.
'''
# Numbers: int and float are different types. A float is IEEE 754 binary64.
replicas: 3
ratio: 0.75
budget: 1_000_000
mask: 0b1010_1100
permissions: 0o644
color: 0xFF8800
flags: 0x00FF
limit: infinity
floor: -infinity
# Booleans and null.
drained: false
owner: null
# Durations: exact, in h, m, s, ms, us, and ns.
retry-after: 1m30s
# Arrays. A comma or a line break separates items.
labels: ['prod', 'eu-west', 'canary']
matrix: [[1, 2], [3, 4]]
mixed: [1, 'two', true, null]
# Objects. The top-level object needs no braces.
pool: {
min: 2
max: 16
}
# Records in arrays. Braces are required, and that is deliberate.
maintainers: [
{name: 'Sindre Sorhus', email: 'sindresorhus@gmail.com'}
{name: 'Random Person', email: 'person@example.com'}
]
# Line comments, and block comments, wherever whitespace may go.
# Inline objects and one-line braced documents work too:
healthcheck: {path: '/health', interval: 10s, grace: 1.5s}
SOML keeps what I like about YAML: comments, no braces around the file, no commas at the ends of lines, and keys without quotes. It removes what hurts: in YAML, the content of a value decides its type. In SOML, the syntax decides, so 'no' is a string, 3.10 is a float, and 2 is an int, in every implementation.
What SOML adds:
/* */, in addition to #.30s and 1h30m, so timeout: 30 no longer makes the reader guess the unit.2026-09-19 and instants such as 2026-09-19T14:00:00Z. YAML 1.2 dropped timestamps, but many parsers still read them, so a date in YAML may or may not be a string.What SOML fixes:
country: no is false, zip: 01234 is octal 668, and time: 22:22 is base-60 1342. YAML 1.2 fixed those, but most of the ecosystem still follows 1.1, and 1.2 still guesses: version: 3.10 is a float but version: 3.10.1 is a string, and ~, Null, and an empty value are null. SOML strings always have quotes, so country: no and version: 3.10.1 are errors.1: x gives an integer key in YAML, and PyYAML reads on: x as the boolean true. SOML keys are always strings, so 404: is the key '404'.| keeps a trailing \n in the value; SOML does not.id: 9007199254740993 into 9007199254740992. A SOML int is an exact int64; a larger one is an error.a: 1 then a: 2, PyYAML keeps 2 and says nothing. The spec forbids duplicates, but after type resolution 1: and 01: may or may not collide, depending on the schema. SOML compares the decoded strings and reports each duplicate.timeout: with no value is null in YAML, and a file with only comments is null in PyYAML. SOML rejects both, so a forgotten value cannot pass as a default.2024-01-01 is a date in PyYAML and a string in js-yaml 5, and 1_000 is 1000 in PyYAML and a string in js-yaml. SOML implementations share one conformance suite of nearly 1000 cases.safe_load is opt-in, and even a loader named “safe” can turn aliases on. SOML has neither, one way to load, and limits on nesting and ranges.SOML keeps what TOML gets right: predictable types, literal strings, insignificant indentation, and duplicate keys as errors. It removes what hurts: table rules where a key's meaning depends on what came before it, and values that parsers silently round, truncate, or read differently. A SOML file means one thing, no matter where a line sits or which parser reads it.
What SOML adds:
/* */, in addition to #.30s and 1h30m, so timeout = 30 no longer makes the reader guess the unit.What SOML fixes:
[server] with port = 8080, server.port = 8080, and server = {port = 8080} all give the same table, each with its own rules. Projects mix them, often in one file. SOML has one way: braces.[[users]] adds to an earlier array, [server.tls] to an earlier table, and a bare key to the last table. An attacker who can put a newline into one value can add a user with role = 'admin'. The same trick hit git config, which is not TOML but is also line-oriented: CVE-2026-44244 injected a [core] section into .git/config for code execution. BurntSushi/toml even lets a later users = ['mallory'] under [auth] silently replace an earlier [auth.users.alice] table. In SOML, earlier objects and arrays are closed.[server] moves every following key into server, even timeout = 30, with no way back to the root. SOML braces make boundaries explicit.[server], logging.level = 'debug' is server.logging.level, but [logging] is a root table, and server.port = 8080 is server.server.port. In SOML, a key belongs to the braces around it.[a.b] then [a] is valid, but a.b.c = 1 then [a] is not. Header and dotted-key tables can be extended, inline tables cannot, and [[servers]] can append to an array of tables, but not to servers = []. SOML has no implicit tables, and one syntax each for objects and arrays.[servers.tls] attaches to the most recent [[servers]], so moving a section can silently attach it to another server. SOML array elements are self-contained objects.3.14 = 'pi', example.com, and tls.crt are nested keys, and servers.0.port creates the key '0', not an array element. SOML has no dotted keys: a dot in a bare key is an error, so you quote it. TOML also needs quotes for a literal dot, but without them it nests silently.[server.database] and [server.cache] repeat the parent name, so a rename that misses one silently creates a separate object. SOML names the parent once.null, but nan. TOML cannot tell an explicit null from a missing key, so writers must drop it or fail: @iarna/toml silently drops a null value, and Python's toml turns None in an array into the string 'None'. TOML has nan. SOML has null, and no nan, which is not equal to itself.1e999 is infinity in Python's tomllib and smol-toml, but an error in Go and Rust, and 1e-400 is zero in all of them. .123456789 seconds is exact in Go and Rust, .123456 in tomllib, and .123 in smol-toml. 9223372036854775808 works in tomllib but not in Rust's toml. SOML ints are exact int64, instants keep nanoseconds, and a float that does not fit is an error.=, so a JSON object cannot be pasted in, not even as a value. SOML uses :, so hand-written JSON is usually valid SOML, and the syntax is already familiar from JSON, YAML, and JavaScript.JSON is unambiguous but noisy to write: the file needs braces around it, every item needs a comma, every key needs quotes, and there are no comments. YAML is pleasant to write but ambiguous to read. SOML keeps JSON's data model and strictness, and also decides what JSON leaves to each implementation. From YAML, it takes comments, multi-line strings, and a top level with no braces or commas, but not implicit typing, significant indentation, aliases, or tags. From TOML, it takes literal strings and the split between int and float, but it allows a top-level array and has no table headers or dotted keys.
| SOML | JSON | YAML 1.2 | TOML 1.1 | |
|---|---|---|---|---|
| Comments | # and /* */ |
no | # |
# |
| Braces around a top-level object | no | required | no | no |
| Commas between items on separate lines | no | required | no | required in arrays and inline tables |
| Keys without quotes | yes, dashes and digits included | no | yes | yes |
| A value's type comes from its syntax | yes | yes | no, an unquoted value is typed by its content | yes |
| Separate int and float | yes | no | yes | yes |
| Dates and durations | date, instant, and duration | no | no, but many parsers still read dates | four date and time types, no duration |
| Duplicate keys | an error | left to the parser | an error, but some parsers keep the last | an error |
| A top-level array | yes | yes | yes | no |
| Indentation changes the meaning | no | no | yes | no |
| Canonical form | yes | only in a separate scheme, RFC 8785 | for scalars only | no |
| Libraries | new, in a few languages | everywhere | everywhere | most languages |
See the full comparison with JSON, JSON5, JSONC, TOML, YAML, and other formats, including where another format is the better choice.
The format is defined in the specification, which also gives the reason for each decision. The FAQ gives short answers to common questions, such as why SOML has durations and separate int and float types.
Use eslint-soml with eslint-plugin-unicorn for additional lint rules. Unicorn provides a recommended-soml preset.
The MIT license applies to the specification itself. You may freely implement this specification without including or referencing the license.