toml
TOML to JSON: Tables, Dates, and Big Integers That Don't Survive
Why pyproject.toml and Cargo.toml fail with "defining a table twice" and duplicate-key errors, what [[array of tables]] actually means, what TOML 1.1 changed about inline tables, and which TOML types (datetimes, 64-bit integers, inf/nan) JSON cannot represent.
TOML has a real specification, unlike INI or .env. Every rule is written down, which is why Cargo.toml and pyproject.toml use it. The same strictness is why a pyproject.toml that looks fine can still fail with “Cannot declare (‘project’,) twice”. TOML errors almost always come from its table-definition rules, and TOML-to-JSON losses almost always come from types JSON doesn’t have. This post walks through both. Every error message and output below comes from running real parsers: Python 3.14’s tomllib, and @iarna/toml 2.2.5, the library behind our TOML ⇄ JSON converter.
(If you’re converting INI rather than TOML, it has the opposite problem: no spec at all. That’s covered in INI to JSON round-trip gotchas.)
[table] vs [[array of tables]]
Single brackets define a table, and you can define it only once. Double brackets append a new table to an array. Cargo uses this for multiple binaries:
[[bin]]
name = "a"
path = "src/a.rs"
[[bin]]
name = "b"
{
"bin": [
{ "name": "a", "path": "src/a.rs" },
{ "name": "b" }
]
}
That’s the whole trick. Each [[bin]] header starts a new element, and the keys below it fill that element in. Because the two forms mean different things, you can’t mix them for the same name. [a] followed by [[a]] fails with Can't redefine an existing key. The reverse order fails the same way. So does a = [1] followed by [[a]]: our parser reports Can't extend an inline array, and the spec agrees that appending to a statically defined array “must produce an error at parse time."
"Defining a table twice” and duplicate keys
The TOML 1.0 spec is blunt about this: “you cannot define a table more than once. Doing so is invalid.” Here’s the mistake people usually make when merging two snippets into one pyproject.toml:
[project]
name = "x"
[project]
version = "1"
Python’s tomllib: Cannot declare ('project',) twice (at line 4, column 9). Our parser: Can't redefine existing key at row 4, col 9. A plain duplicate key (name = "a" then name = "b") gets the same error. There’s no last-one-wins rule, unlike most JSON parsers.
Dotted keys are the subtler case. A dotted key like apple.color = "red" implicitly defines the table apple, so a later header for that same table counts as a redefinition. The spec’s own example marks this as invalid:
[tool]
poetry.name = "x"
[tool.poetry] # redefines tool.poetry
version = "1"
tomllib rejects it with Cannot declare ('tool', 'poetry') twice (at line 4, column 13). Our converter accepts it and merges the two into one tool.poetry table. @iarna/toml implements TOML 0.5, and it’s more lenient here than 1.0 requires. So a file that converts cleanly on our site can still fail in pip install or Poetry. When in doubt, use one style per table: either headers or dotted keys, not both. You can add a sub-table under a dotted-key table: [tool.poetry.dependencies] after poetry.name is valid.
Inline tables: one line, sealed, and what TOML 1.1 changed
Inline tables ({ ... }) are the most common source of confusing errors, because they look like JSON objects and people write them like JSON objects. In TOML 1.0 they have two rules:
- “No newlines are allowed between the curly braces unless they are valid within a value,” and “a terminating comma is not permitted after the last key/value pair.”
- “Inline tables are fully self-contained.” You can’t add keys to one later with a header.
So this Cargo-style dependency block is invalid TOML 1.0:
deps = { serde = "1",
tokio = "1" }
tomllib reports Invalid initial character for a key part (at line 1, column 22). Our parser reports Unterminated inline array at row 1, col 23. That message is misleading, since it’s a table, not an array, but the location is right. Rule 2 catches a = { x = 1 } followed by [a.b]: Can't redefine existing key.
TOML 1.1.0 was released on 2025-12-18 and relaxes rule 1. According to the official changelog, inline tables may now contain newlines and a trailing comma. Other 1.1 changes: seconds are optional in times (07:32), there’s a new \e escape, and \xHH works for codepoints up to 255. toml.io now lists v1.1.0 as the current version. Parsers are catching up, though. Python 3.14’s tomllib still rejects every 1.1 feature we tried: multi-line inline tables, a trailing comma, x = 07:32, "\e", and "\x41" all fail. Our 0.5-era parser rejects multi-line inline tables and 07:32 (Incomplete time), but it accepts { x = 1, }, a trailing comma that 1.0 forbids. The takeaway: a multi-line inline table is valid TOML now, but whether it parses depends entirely on which parser reads it. For pyproject.toml, keep inline tables on one line until your whole toolchain supports 1.1.
Four datetime types, zero in JSON
TOML has four first-class date/time types. JSON has none. Every converter has to turn them into strings, and each one decides how:
odt = 1979-05-27T07:32:00-08:00 # offset date-time
ldt = 1979-05-27T07:32:00 # local date-time
ld = 1979-05-27 # local date
lt = 07:32:00 # local time
Our converter’s actual output:
{
"odt": "1979-05-27T15:32:00.000Z",
"ldt": "1979-05-27T07:32:00.000",
"ld": "1979-05-27",
"lt": "07:32:00.000"
}
Three things changed. First, the offset date-time was normalized to UTC. The instant is the same, but the -08:00 you wrote is gone. Second, milliseconds were padded onto every time. Third, a sub-millisecond value like 00:32:00.999999-07:00 comes out as 07:32:00.999Z: truncated to milliseconds, which is exactly the minimum the spec requires (“Millisecond precision is required”). Python keeps all four as distinct datetime, date, and time objects with the offset intact. Once they’re JSON strings, a reader can’t tell a date from a string that happens to look like one. That also means the trip back doesn’t round-trip. JSON→TOML writes "1979-05-27T07:32:00Z" as a quoted string, not a datetime. If you work with timestamps across zones, store UTC, display local explains why the offset matters.
64-bit integers vs. JavaScript’s 2^53
TOML says “arbitrary 64-bit signed integers (from −2^63 to 2^63−1) should be accepted and handled losslessly.” JavaScript numbers are doubles, which are exact only up to 2^53 (9,007,199,254,740,992). IDs, Snowflake-style keys, and hashes routinely go past that.
@iarna/toml handles the parse side properly by returning a BigInt for anything outside the safe range. The problem came next. JSON.stringify throws on BigInt, and until this post our converter passed that error straight through: Do not know how to serialize a BigInt. We found this while writing this post and fixed it. Big integers now come out as exact JSON number literals, because JSON’s grammar puts no limit on digits:
snowflake = 1152921504606846977
{
"snowflake": 1152921504606846977
}
Whether the number stays exact after that depends on who reads it. Python’s json.loads gives back 1152921504606846977. JavaScript’s JSON.parse gives 1152921504606847000. The same problem hits the JSON→TOML direction: the browser’s JSON.parse has already rounded the number before the TOML writer sees it, so {"id": 1152921504606846977} comes out as id = 1_152_921_504_606_847_000, which is wrong. If an ID is bigger than 2^53, keep it as a string in JSON.
We also found one limitation we couldn’t fix at our layer. Out-of-range integers wrap around silently in @iarna/toml instead of raising the error the spec requires. x = 9223372036854775808 (2^63) parses as -9223372036854775808, and 18446744073709551616 (2^64) parses as 0. Values that large are rare in config files, but check any number near 2^63 by hand.
inf, nan, and other floats JSON can’t spell
TOML allows inf, -inf, and nan (lowercase). JSON has no way to write any of them, and JSON.stringify quietly turns all three into null. Our converter outputs {"a": null, "b": null} for a = inf and b = nan, and so does a float literal that overflows a double, like x = 1e400. That’s lossy, and the output doesn’t warn you. If your TOML has sentinel floats, expect null in the JSON and handle it downstream.
Heterogeneous arrays: legal since 1.0, rejected by our parser
TOML 0.5 required every element of an array to have the same type. TOML 1.0 removed that restriction: “Values of different types may be mixed.” tomllib parses a = [1, "two", 3.0, {x=1}] without complaint. Our converter rejects it: Inline lists must be a single type, not a mix of integer and string. This is a real spec gap in @iarna/toml, not a problem with your file. Nested arrays with different element types ([[1,2],["a","b"]]) did pass even under 0.5. The JSON→TOML direction has the same limitation: {"a":[1,"two"]} fails with Array values can't have mixed types.
JSON → TOML: what has nowhere to go
null. TOML has no null type at all. Before this post, our converter dropped null keys and null array elements without a word: {"a":1,"b":null} came out as a = 1, and [1,null] became [ 1 ]. That’s silent data loss, and it was the second bug we fixed. The converter now refuses and names the path:
TOML has no null type: found null at "license". Remove the key or replace it with a value (e.g. "" or false).
Mixed arrays of tables and scalars. {"a":[{"x":1}, 2]} can’t be written as [[a]] (every element must be a table), and our writer won’t emit a mixed inline array either, so it errors.
Key order. The spec says key/value pairs “are not guaranteed to be in any specific order.” Order is preserved in practice (JavaScript objects keep insertion order for string keys), but TOML syntax forces plain keys to come before sub-tables. So {"nested":{"q":1},"z":1,"a":2} becomes z = 1, a = 2, then [nested]. The reordering is correct, but it shows up in a diff.
Cosmetics. Large integers get digit separators (9_007_199_254_740_992), empty objects become e = { }, and keys with spaces or dots are quoted ("dotted.key" = 2). All of these are valid TOML, just not necessarily how you’d write it by hand.
TOML → JSON type map
| TOML type | Example | JSON output (our converter) | Lossless? |
|---|---|---|---|
| String | "x" | "x" | Yes |
| Integer ≤ 2^53 | 42, 0xDEAD, 1_000 | 42, 57005, 1000 | Value yes, notation no |
| Integer > 2^53 | 9007199254740993 | 9007199254740993 (exact literal) | In the text; JS JSON.parse rounds it |
| Float | 3.14 | 3.14 | Yes |
inf / nan | inf | null | No |
| Boolean | true | true | Yes |
| Offset date-time | …T07:32:00-08:00 | "…T15:32:00.000Z" | Instant yes, offset no |
| Local date-time | 1979-05-27T07:32:00 | "1979-05-27T07:32:00.000" | As a string only |
| Local date | 1979-05-27 | "1979-05-27" | As a string only |
| Local time | 07:32:00 | "07:32:00.000" | As a string only |
| Table / inline table | [t], { a = 1 } | object | Yes (inline vs. header lost) |
| Array of tables | [[bin]] | array of objects | Yes |
| Comment | # why | (dropped) | No |
TOML vs YAML vs JSON, briefly
JSON has no comments, no dates, and no integers beyond what the reader supports. It’s the interchange format. YAML has comments and implicit typing, which is powerful and famously surprising (no → false in YAML 1.1). The YAML Norway problem shows which parsers still do that, and the other values that change type. TOML has comments and explicit, typed datetimes, and it has a strict rule that everything is defined exactly once. The strictness costs you the errors above. What you get in return is that a valid TOML file means the same thing to every compliant parser. For YAML, the YAML ⇄ JSON converter covers the same conversions, and the INI ⇄ JSON converter handles legacy .ini files.
The short version
[t]defines a table once.[[t]]appends to an array. Don’t mix the two, and don’t redefine a table with a header after dotted keys already defined it. Stricter parsers reject that even though ours doesn’t.- Duplicate keys are a hard error, not last-one-wins.
- Inline tables must be single-line with no trailing comma in TOML 1.0. TOML 1.1 (Dec 2025) allows both, but Python 3.14’s
tomllibdoesn’t support 1.1 yet. - TOML’s four datetime types become strings in JSON. Offsets are normalized to UTC in ours.
- Integers above 2^53 now come out exact in our TOML→JSON output. JavaScript readers still round them.
inf/nanbecomenull. JSONnullhas no TOML form, and our converter now tells you where it is.
Paste your file into TOML ⇄ JSON to see exactly what it produces. It runs entirely in your browser.