Json
-
Your JSON Parser Disagrees With Mine
Markdown has too many specs. CSV has one that nobody agreed to follow. JSON is supposed to be the happy ending: a grammar small enough to print on a business card, standardized twice over, and elevated to a full Internet Standard.
It is, and the parsers still disagree with each other about what your file says.
Not about whether it’s valid. About what the values are.
The Same Number, Three Answers
Here is a JSON document. It is unambiguously valid by every specification.
{"id": 9007199254740993}Three parsers, on the same machine (Node 24.13.0, Python 3.13.12, jq 1.8.2), on those exact bytes:
node : {"id":9007199254740992} python : {"id": 9007199254740993} jq : {"id":9007199254740993}Node gave back a different number than the one in the file. It didn’t error, didn’t warn, didn’t round-trip. The last digit changed from 3 to 2.
The reason is that JSON’s grammar allows a number to be any sequence of digits, but JavaScript represents every number as an IEEE 754 double. Above 2⁵³ the integers stop being exactly representable, and
Number.MAX_SAFE_INTEGERis 9007199254740991. Our value is two past it.This is not a JavaScript bug. Node is behaving exactly as specified. The specification simply declines to say how many digits a number may have, and every implementation answers that question with whatever its host language does.
Those version numbers matter, which is its own version of the problem.
jqonly began preserving decimal literals in 1.7, whose release notes list “use decimal number literals to preserve precision.” Run that same file through jq 1.6 and it goes through a double and hands you Node’s answer. The tool doesn’t just disagree with other parsers. It disagrees with its own past self.If you have ever wondered why APIs send 64-bit IDs as strings, this is why. Twitter’s snowflake IDs, database primary keys, anything above 2⁵³ has to be quoted or it silently degrades in half the ecosystem.
Duplicate Keys Are Legal
{"role": "user", "role": "admin"}RFC 8259 says names within an object should be unique. Should, not must. And it goes on to describe what happens otherwise as varying between implementations.
In practice:
node : {"role":"admin"} python : {"role": "admin"} jq : {"role":"admin"}All three take the last one. That’s the common behavior, and it is not required.
The RFC itself spells out all three possibilities:
When the names within an object are not unique, the behavior of software that receives such an object is unpredictable. Many implementations report the last name/value pair only. Other implementations report an error or fail to parse the object, and some implementations report all of the name/value pairs, including duplicates.
That third option, keeping both, isn’t even representable in most languages’ object types. Nicolas Seriot tested parsers across a dozen languages against cases like this one and concluded there are “no two parsers that agree on what is wrong and what is right.”
Python will show you both if you ask:
raw pairs: [('role', 'user'), ('role', 'admin')]The pairs are all there in the document. Choosing one is an interpretation layered on top of parsing.
Now put two parsers in one system. Apache CouchDB did, and it became CVE-2017-12635.
CouchDB used an Erlang parser for authentication and a JavaScript engine for the validation that runs when a document is written. The Erlang parser resolved duplicate keys to the first value. The JavaScript engine resolved them to the last. So a request like this:
{"roles": ["_admin"], ..., "roles": []}was read by the write-time validation as an ordinary unprivileged user, because it saw the last
roleskey and found it empty. It was then read by the authentication layer as an administrator, because that saw the first one. Non-admin users could grant themselves admin.CouchDB’s fix was to change the Erlang parser to take the last key, matching JavaScript. Not because last-wins is correct, but because agreeing is correct.
This is the JSON version of the ZIP two-index problem from a few posts back. When a format permits two answers to the same question, the gap between two components is where the vulnerability lives.
NaN Is Not JSON, and Python Emits It Anyway
JSON has no way to express not-a-number or infinity. The grammar has no room for them.
Python’s standard library writes them regardless:
>>> json.dumps({"a": float("nan"), "b": float("inf")}) '{"a": NaN, "b": Infinity}'That output is not JSON. It’s Python’s default behavior, and it produces a file that other parsers reject or mangle. Feeding those exact bytes onward:
node : SyntaxError - Unexpected token 'N', "{"a": NaN, "b": "... is not valid JSON jq : {"a":null,"b":1.7976931348623157e+308}Node’s response is correct and useful: this is not JSON, here’s where it broke.
jq’s response is the one that should worry you. It accepted the invalid document and made up values.NaNbecamenull.Infinitybecame1.7976931348623157e+308, the largest finite double. No error, no warning. If that ran in the middle of a data pipeline you would get numbers out the other end, and they would be wrong in a way no downstream check is likely to catch.The same divergence shows up with a merely-enormous exponent, which is valid JSON:
input: {"v": 1e999} node : {"v":null} python : {'v': inf} jq : {"v":1E+999}Three parsers, one valid input, three different values. Node converts to infinity then serializes it as
nullbecause it can’t represent infinity on the way out. Python gives you a float infinity object.jqpreserves the literal.
Why a Small Spec Doesn’t Save You
JSON’s specifications are good, and they are small. The problem is that they specify syntax, and almost every failure above is about semantics.
The grammar tells you
9007199254740993is a well-formed number. It does not tell you what number it is, because that would require committing to a numeric model, and committing to a numeric model would have meant excluding some language from implementing JSON natively. The looseness is why JSON is everywhere. It is also why the same bytes mean different things in different places.The standards process eventually acknowledged this. RFC 7493 defines I-JSON, a restricted profile that closes these holes: no duplicate names at all, numbers that should not exceed what an IEEE 754 double holds exactly, high-precision values recommended to travel as strings, and mandatory UTF-8. Only the duplicate-name rule is a hard
MUST NOT, which tells you something about how much of this was still negotiable in 2015.I-JSON is what most people think JSON already is. It exists as a separate document precisely because JSON isn’t that.
What To Do About It
- Send large integers as strings. Anything that could exceed 2⁵³: IDs, timestamps in nanoseconds, financial values in minor units.
- Reject duplicate keys at your trust boundary rather than letting your parser pick. Most libraries offer a hook.
- Don’t let a language’s default serializer decide whether it emits valid JSON. Python needs
allow_nan=Falseto be honest. - Validate before you transform. A parser that repairs invalid input is more dangerous than one that rejects it, because the repair is silent.
- Target I-JSON for anything crossing a system boundary. It costs nothing and removes the whole category.
JSON did not fail. It succeeded so completely that it got implemented hundreds of times by people reading a short document, and a short document leaves a lot of decisions to the reader. The format that’s easy to implement is the format that gets implemented differently everywhere.
That’s the same sentence I could have written about Markdown, and about CSV. The pattern across this whole series is that a format’s ambiguities don’t stay theoretical. They become somebody’s incident.
Sources
- RFC 8259 — the current JSON standard, and STD 90
- RFC 7493 — I-JSON, the profile that closes the interoperability holes
- ECMA-404 — the parallel Ecma grammar standard
- Nicolas Seriot, “Parsing JSON is a Minefield” — the systematic survey of parser disagreement
- JSONTestSuite — the executable test corpus behind that research, over 300 cases
- CouchDB’s writeup of CVE-2017-12635 — the duplicate-key privilege escalation, in the vendor’s own words
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
-
Adding Types to JSON with Dhall
A few months ago I wrote a post asking whether there’s something better than JSON. Two configuration languages that sit above JSON kept coming up: CUE and Dhall. Both give you the things JSON lacks when you author config by hand, and both compile down to plain JSON, YAML, or whatever your services actually read. I spent more time with CUE back then and never gave Dhall a real look. This post is me going back for that second look, because the one feature I kept wanting was a type system over my config.
JSON is the universal language of API payloads and config files, and I don’t want that to change. But as a format for authoring configuration by hand, it’s rough:
- No comments.
- No variables or functions, so you copy-paste the same block ten times.
- No type system, so
"8080"and8080look equally valid. - No imports, which is how you end up with a 2,000-line monolith nobody wants to touch.
The usual escape hatch is a templating engine like Jinja or Helm, or a real programming language like Python or TypeScript that spits out JSON. That works, but you’ve traded one problem for a scarier one: your config generator is now Turing-complete. It can crash, hang in an infinite loop, or reach out and read some local environment variable, and it’ll do it at 2 AM when the pipeline runs.
This is where Dhall comes in.
What is Dhall?
The short version: Dhall is JSON plus types, plus functions, plus imports. It’s a programmable, strongly-typed configuration language.
The part I actually care about is what it doesn’t have. Dhall is not Turing-complete. No arbitrary recursion, no side effects. Every Dhall program is guaranteed to terminate. You get the abstraction power of a functional language like Haskell or Elm, with the guarantee that it will never hang your build. That’s a different trade than “just write a Python script.”
The problem, in JSON
Here’s a normal
config.jsonfor a microservice:{ "serviceName": "payment-api", "port": 8080, "environment": "production", "database": { "host": "db.internal.net", "maxConnections": 50 } }Three ways can be a problem in production: someone writes
"port": "8080"and the service won’t boot, someone typos"prodution"and it silently runs in debug mode, or someone forgetsmaxConnectionsentirely and you get a null blowup at runtime. Nothing catches any of it until it’s live.The same thing, typed
In Dhall you define the shape up front. Enums, record types, default values:
-- schema.dhall let Environment = < Local | Staging | Production > let Database = { Type = { host : Text, maxConnections : Natural } , default = { maxConnections = 20 } } let Config = { Type = { serviceName : Text , port : Natural , environment : Environment , database : Database.Type } , default = { port = 8080, environment = Environment.Local } } in { Environment, Database, Config }Now you author against that schema, and you get defaults and composition for free:
-- config.dhall let Schema = ./schema.dhall let myConfig : Schema.Config.Type = Schema.Config.default // { serviceName = "payment-api" , environment = Schema.Environment.Production , database = Schema.Database.default // { host = "db.internal.net", maxConnections = 50 } } in myConfigMisspell
Production, or pass"8080"as a string, and Dhall throws a type error before a single line of JSON is generated. Hopefully the benfit is now clear; adding a type safety layer to your config files.Compiling down to JSON
You don’t ship Dhall to your services. You ship the JSON they already understand:
brew install dhall-json dhall-to-json --file config.dhallOut comes clean, boring, standard JSON. Your services never know Dhall was involved. The part that I like is the safety lives at authoring time, and the runtime artifact stays dumb.
Two features worth knowing about
Hermetic imports with hash pinning. Dhall can import from a URL, so shared utilities live in one place instead of being copy-pasted across five repos. To keep someone from swapping the file out from under you, you pin the import to a SHA-256 hash of its normalized form:
let Prelude = https://prelude.dhall-lang.org/v22.0.0/package.dhall sha256:10db4c919c25e4d262db3ed0d1d6120da3e3906673f00e3012c1d14e1963976aIf the remote content changes, the hash won’t match and the build fails. The hash above is just an example, and each Prelude version has its own, so don’t copy it by hand.
dhall freeze --inplace config.dhallcomputes the correct hashes for whatever you’ve imported and pins them automatically.Exhaustive matching with
merge. When you map a union type to output, Dhall makes you handle every variant:let getLogPrefix = \(env : Environment) -> merge { Local = "[DEV] ", Staging = "[STAGE] ", Production = "[PROD] " } envAdd a
QAvariant later, and everymergeblock that touchedEnvironmentfails to compile until you deal with it. No forgottenswitchcase slipping into production. The compiler keeps a running list of everything you now owe it.Is it worth it?
Raw JSON Dhall Type safety None, fails at runtime Static, at compile time Comments & logic No Yes Termination N/A Guaranteed Dependency pinning No SHA-256 Output Consumed directly Compiles to JSON/YAML/TOML For a two-key config file, it doesn’t make sense, but once you’re staring down Kubernetes manifests, a pile of near-identical microservice configs, or anything where a typo takes down a service, the calculus changes. You keep clean static JSON as the thing your services actually read, and you move all the ways-to-get-it-wrong to a place where a compiler catches them first.
Sources
- Dhall Language Tutorial & Cheatsheet: records, union types, default overrides,
dhall-to-json, anddhall freeze. - Dhall language standard on GitHub: the non-Turing-complete design and the semantic integrity hash spec.
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
-
I Benchmarked JSON Parsing in Bun, Node, Rust, and Go
I’m just going to start posting about JSON everyday. Well ok, maybe not every day, but for the next few days at least. Later this week I’ve committed to writing a guide on getting started with CLIs for non-programmers, so stay tuned for that.
This morning I benchmarked JSON parsing across four runtimes: Bun, Node, Rust, and Go.
The Results
- Bun is the overall winner on large files — 307-354 MB/s, beating even Rust’s serde_json for untyped parsing
- Rust wins on small/nested data (225 MB/s small, 327 MB/s nested) due to low overhead
- Node is close behind Bun — V8’s JSON.parse is very optimized
- Go is ~3x slower than the JS runtimes on large payloads (encoding/json is notoriously slow)
- Memory: Bun reports 0 delta (likely GC reclaims before measurement), Rust’s tracking allocator shows the true heap cost (73-96MB), Go uses 52-65MB
Rust’s numbers were the most honest here since the tracking allocator catches everything. We should take Bun result with grain of salt because benchmarking memory in GC’d languages is tricky.
The json parser in v8 in node is the exact same as what is in Chrome…
Here’s the full test results if you want to dig into the numbers yourself.
More JSON content coming soon. You’ve been warned.
-
Is There Something Better Than JSON?
Have you ever looked at a JSON file and thought, “There has to be something better than this”? I have.
JSON has served us well. It works with everything, and it’s human readable. It’s a decent default, don’t get me wrong, but the more you use it, you’ll find its limitations to be quite painful. So before we answer the question of whether there’s anything better, we should describe what’s actually wrong with JSON.
The Problems with JSON
First, there’s no type system. No datetimes, no real integers, no structs, no unions, no tuples. If you need types, and you almost always do, you’re on your own.
Second, JSON is simple, which sounds like a feature until you try to store anything complicated in it. You end up inventing your own schema, and the schema tooling out there (JSON Schema, etc.) gets verbose fast. Because the spec is so loose, validation can be inconsistent across implementations.
There’s more: fields can be reordered, you have to receive the entire document before you can start verifying it, and there are no comments. You can’t leave a note for the next person explaining why a config value is set a certain way. That’s a real problem for anything that lives in version control.
The Machine-Readable Alternatives
Now, there are plenty of binary serialization formats that solve some of these issues. Protobuf, Cap’n Proto, CBOR, MessagePack, BSON. They’re all interesting and have their place. But they’re machine readable, not human readable. You can’t just open one up in your editor and make sense of it. So let’s set those aside.
The question I’m more interested in is: is there something better than JSON that you can still read and edit as a text file?
It turns out there are two solid options.
Dhall
Dhall is a programmable configuration language. Think of it as JSON with all the things you wish JSON had: functions, types, and imports. You can convert JSON to Dhall and back, and it’s just a text file you can open in any editor. The name comes from a character in an old video game, and the language itself is interesting enough that it’s worth your time to explore.
CUE
CUE stands for Configure, Unify, and Execute. It’s similar to Dhall in that it fills the gaps JSON leaves behind, like types, validation, and constraints, while staying human readable. Where CUE really pulls ahead is in its feature set. You can import Protobuf definitions, generate JSON Schema, validate existing configs, and a lot more. In terms of raw capabilities, CUE has more going on than Dhall.
JSON isn’t going anywhere. But if you’re looking for something interesting to explore, check out both of these. They make great fun little side projects.