Developer · 5 min read

JSON Formatting: The Rules People Get Wrong

JSON is small enough to specify on a business card, which is why almost nobody reads the specification. Then the parser rejects something and it is not obvious why.

JSON is defined by RFC 8259, which is about fifteen pages including the boilerplate. The grammar itself is six productions. This is a format you could reasonably implement over a weekend, and that minimalism is the point.

It also means there are no escape hatches. Everything JSON does not support, it does not support at all.

The entire type system

JSON has exactly six types:

  • object: unordered key/value pairs, keys must be strings
  • array: ordered values
  • string: double-quoted, Unicode
  • number: decimal, optional exponent
  • boolean: true or false, lowercase
  • null

No dates. No integers as distinct from floats. No binary. No undefined. No NaN or Infinity. No comments. Every one of those absences produces a recurring class of bug.

Five things that will get rejected

1. Trailing commas

{
  "name": "Ada",
  "role": "engineer",     <- fine
}                          <- the comma above is not

JavaScript permits this. JSON does not. If you are hand-editing a config file this is the error you will hit most often.

2. Single quotes

{ 'name': 'Ada' }   ✗  invalid
{ "name": "Ada" }   ✓  valid

Both keys and string values require double quotes. Unquoted keys (legal in JavaScript) are also invalid.

3. NaN and Infinity

These are not JSON values. JSON.stringify(NaN) returns the string "null", silently, which means a numeric pipeline can lose the distinction between "no value" and "computation failed" without raising anything. If that distinction matters, encode it explicitly as a string or a status field.

4. Comments

Not supported at any position. The conventional workaround:

{
  "_comment": "timeout is in milliseconds",
  "timeout": 30000
}

It works because _comment is just a key. It is not elegant, but it survives round-tripping through any parser.

5. Duplicate keys

{ "id": 1, "id": 2 }

RFC 8259 calls the behaviour here undefined. Most implementations take the last value; some take the first; a few raise an error. This has real security implications: if a proxy and a backend parse the same document with different tie-breaking rules, they see different documents. It has been used to bypass validation layers. Reject duplicates explicitly if you are parsing untrusted input.

The number problem

This one is subtle and it bites in production.

JSON numbers have no specified precision limit. The grammar allows arbitrarily many digits. But JavaScript parses every number as an IEEE 754 double, which holds integers exactly only up to 253 − 1, or 9,007,199,254,740,991.

JSON.parse('{"id": 9007199254740993}')
// { id: 9007199254740992 }   <- silently wrong

The value changed and nothing was thrown. Any 64-bit identifier (Twitter/X snowflake IDs, database bigints, many distributed-system keys) is at risk.

The fix is to transport large integers as strings:

{ "id": "9007199254740993" }

Ugly, and correct. Several major APIs do exactly this, sometimes shipping both forms (id and id_str) for compatibility.

Decimals have the same issue in a different guise: 0.1 + 0.2 is not 0.3 in binary floating point. Never store monetary amounts as JSON numbers. Use integer minor units (cents, paise), or a decimal string.

Dates

JSON has no date type, so a convention is needed. Use ISO 8601 in UTC:

{ "created": "2026-07-25T14:30:00Z" }

It sorts lexicographically, it is unambiguous, and every language can parse it. Date.prototype.toJSON() produces exactly this format.

What to avoid: Unix timestamps without units (seconds or milliseconds? the reader cannot tell), local times without an offset, and any regional format. 03/04/2026 is two different days depending on which side of the Atlantic you are on.

Encoding and escaping

RFC 8259 requires UTF-8 for data exchanged between systems. Not UTF-16, not Latin-1, and no byte order mark. A BOM will cause many parsers to fail on the first character.

Inside strings, these must be escaped: ", \, and all control characters below U+0020. Optionally escapable: /, which exists purely so you can write <\/script> and avoid terminating a script block early when JSON is embedded in HTML.

Characters outside the Basic Multilingual Plane (emoji, many CJK extensions) need surrogate pairs if you escape them:

"\uD83D\uDE00"   // 😀 U+1F600

Usually you should just emit the UTF-8 bytes directly and not escape at all.

Structural advice

Return an object at the top level, not an array. An array leaves you nowhere to add pagination, metadata or error information later without a breaking change. {"items": [...], "total": 240} is extensible; [...] is not.

Pick one key convention and keep it. snake_case or camelCase, consistently. Mixed conventions in one payload are a reliable sign of two teams that never spoke.

Distinguish null from absent deliberately. A key present with value null can mean "known to be empty"; an absent key can mean "not supplied" or "not applicable". Whichever you choose, document it. PATCH semantics depend on the difference.

Avoid deep nesting. Past three or four levels, access paths become unreadable and partial updates become painful. Flatten, or split into separate resources.

Pretty-printed or minified

Pretty-print for anything a human reads: config files, fixtures, documentation, files under version control. Two-space indentation is the common default, and line-based diffs work properly.

Minify for transport. Whitespace is typically 10-20% of a payload, and although gzip removes most of that redundancy, the parse cost is real at scale.

You do not have to choose permanently. The JSON formatter converts in both directions, and does it in the browser, which matters when the payload you are debugging contains customer data or an API key. Pasting production JSON into a server-side formatter is a habit worth breaking.

When JSON is the wrong choice

  • Config files that need comments belong in TOML or YAML. JSONC works if your tooling supports it.
  • Large numeric datasets belong in Parquet, Arrow or plain CSV. JSON repeats every key on every record, which is enormously wasteful in columnar-shaped data.
  • High-throughput RPC is better served by Protocol Buffers or MessagePack. Both are smaller, faster to parse and schema-checked.
  • Documents with mixed content still belong in XML, which wins where text and markup interleave.

JSON is a good default for APIs precisely because it is boring and universally supported. It is not a good default for everything.

Common questions

Why does my JSON fail with a trailing comma?

Because the grammar does not permit one. JavaScript object literals allow trailing commas and JSON does not, which is the single most common source of confusion given the shared ancestry. JSON5 and JSONC allow them; strict JSON parsers will not.

How do I put a comment in JSON?

You cannot, in standard JSON. Douglas Crockford removed comments deliberately, having seen people use them to smuggle in parsing directives. The usual workaround is a key such as "_comment", which is ugly but valid. For config files where comments genuinely matter, JSONC, YAML or TOML are better fits.

Is key order preserved?

The specification says objects are unordered, so you cannot rely on it. In practice most parsers preserve insertion order and JavaScript’s JSON.parse does for string keys. Depending on it anyway makes your code fragile across languages. Python dicts preserve order, Go maps deliberately randomise it.