JSON Syntax

Working with JSON

Chapter 1 ยท What JSON Is: Syntax, Types and Rules

JSON looks like a JavaScript object and is read by every language, so people treat it as "obvious" and stop checking. Then a file with a stray comma fails, one parser accepts NaN while another rejects it, and an ID quietly changes its last digits. This chapter is the small rulebook behind all of that. You will learn the six value types, the exact list of things JSON forbids, the rules for numbers and strings, where two real parsers disagree, and how to read their error messages. The rest of the course (JavaScript, Python, jq, JSON Schema) builds on this.

Every example here was run, except where marked
Node 24.18.0 (JSON.parse) and Python 3.14.0 (json and python -m json.tool) on Windows 11, in a scratch folder outside this site. Error messages are copied from real output. They are the messages of those versions: other Node versions, Firefox, Safari and other Python versions word them differently. Not run: VS Code, Firefox and Safari. Statements about the standards are checked against RFC 8259 (the text is quoted where it matters).

What JSON Is

JSON (JavaScript Object Notation) is a text format for data. A JSON document is just characters: it is not an object, and it is not JavaScript. A program parses the text into its own values (an object in JavaScript, a dict in Python) and serialises values back into text. It is defined twice, in matching terms: RFC 8259 from the IETF and ECMA-404 from Ecma International. Its media type is application/json, and RFC 8259 says text exchanged between systems outside a closed ecosystem must be encoded as UTF-8.

The format is deliberately tiny: six kinds of value, and a handful of punctuation marks. Here is one document that uses all six:

{ "name": "Ada Lovelace", "born": 1815, "mathematician": true, "spouse": null, "languages": ["English", "French"], "address": { "city": "London", "postcode": null } }
TypeWritten asNotes
string"text"Double quotes only; Unicode; escapes with a backslash
number42 -3.5 1e3One number type, written in decimal; no hex, no NaN, no Infinity
booleantrue falseLower case only
nullnullLower case only; there is no undefined
array[1, "two", null]Ordered; items may be of different types
object{"key": value}Keys are always strings in double quotes; the keys should be unique

A whole JSON text is one value. RFC 8259 says "A JSON text is a serialized value", and older specifications required an object or array at the top. Today 42, "hello" and null are each a valid document. I parsed all six kinds at the top level in Node and each came back as the matching type; only [] reported as an array. Some older tools still insist on an object or array at the top, so an API that returns a bare string may meet a parser that rejects it.

What JSON Forbids

Most JSON errors are people writing things that are fine in JavaScript, or in a hand-edited config file, but are not in the JSON grammar. I fed each of these to JSON.parse in Node and json.loads in Python and recorded the message:

What you wroteNode 24.18 saidPython 3.14 said
{"a":1 /* c */} a commentExpected ',' or '}' after property value in JSON at position 7 (line 1 column 8)Expecting ',' delimiter: line 1 column 8 (char 7)
{"a":1,} trailing commaExpected double-quoted property name in JSON at position 7 (line 1 column 8)Illegal trailing comma before end of object: line 1 column 7 (char 6)
[1,2,] trailing commaUnexpected token ']', "[1,2,]" is not valid JSONIllegal trailing comma before end of array: line 1 column 5 (char 4)
{'a':1} single quotesExpected property name or '}' in JSON at position 1 (line 1 column 2)Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
{a:1} unquoted keyExpected property name or '}' in JSON at position 1 (line 1 column 2)Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
{"a":undefined}Unexpected token 'u', "{"a":undefined}" is not valid JSONExpecting value: line 1 column 6 (char 5)
{"a":01} leading zeroUnexpected number in JSON at position 6 (line 1 column 7)Expecting ',' delimiter: line 1 column 7 (char 6)
{"a":+1} plus signUnexpected token '+', "{"a":+1}" is not valid JSONExpecting value: line 1 column 6 (char 5)
{"a":0x1F} hex numberExpected ',' or '}' after property value in JSON at position 6 (line 1 column 7)Expecting ',' delimiter: line 1 column 7 (char 6)
{"a":NaN}Unexpected token 'N', "{"a":NaN}" is not valid JSONaccepted: {'a': nan}
a raw tab or newline inside a stringBad control character in string literal in JSON at position 7 (line 1 column 8)Invalid control character at: line 1 column 8 (char 7)
{"a":1}{"b":2} two valuesUnexpected non-whitespace character after JSON at position 7 (line 1 column 8)Extra data: line 1 column 8 (char 7)
an empty stringUnexpected end of JSON inputExpecting value: line 1 column 1 (char 0)

So the full list of things people try and JSON does not allow: comments, trailing commas, single-quoted strings, unquoted keys, undefined, numbers with a leading zero, a leading plus, a leading or trailing dot, or in hex, NaN and Infinity, and raw control characters inside strings. The one surprise in the table is Python's NaN, covered below. Comments are the one people miss most: if you want them in a config file, JSON is the wrong format, or you need a superset such as JSONC or JSON5 (Working with JSON 10 compares them).

Numbers

JSON numbers are written in decimal: an optional minus, digits (no leading zero unless the number is just 0), an optional fraction, an optional exponent. The standard sets no limit on size or precision: RFC 8259 says implementations "set limits on the range and precision of numbers accepted". That sentence is where the trouble starts, because each language picks its own limit. I parsed the same four numbers in both:

JSON textNode (JavaScript number)Python
1234567890123456789012345678901234567000 (digits lost)12345678901234567890 (int, exact)
9007199254740993 (253 + 1)9007199254740992 (off by one)9007199254740993 (exact)
1e400Infinityinf
-0-0 (kept; Object.is(x, -0) is true)0 (an int)
1.01 (JavaScript has no 1.0)1.0 (a float)

The same JSON text gives different numbers in different languages. The practical rule: a JavaScript number is a 64-bit float, so integers are exact only up to 9007199254740991 (Number.MAX_SAFE_INTEGER). An ID larger than that, read by JavaScript, silently changes. That is why large IDs are often sent as strings. Working with JSON 11 returns to this with a demonstration. Notice also that 1e400 was accepted by both parsers and became infinity, even though the standard has no infinity. Parsing succeeds; what happens to the value is the language's business.

Strings and Escapes

A string is wrapped in double quotes and can contain any Unicode character except the quote, the backslash and the control characters (code points below U+0020), which must be escaped. There are exactly eight short escapes plus the \uXXXX form. I parsed each short escape inside a string and read back the character code:

WriteMeansCode read back
\"double quote34
\\backslash92
\/forward slash (optional; / needs no escape)47
\b \f \n \r \tbackspace, form feed, newline, carriage return, tab8, 12, 10, 13, 9
\u00e9the character with that 4-digit hex code (here รฉ)e9 (hex)

Anything else after a backslash is an error. I tried three: \x41 (the JavaScript hex escape) gave Bad escaped character in JSON at position 7; \u41, with only two digits, gave Bad Unicode escape in JSON at position 10; and the capital \U0041 gave Bad escaped character too. JSON has no \x, no \' and no \0.

Characters outside the first 65,536. \uXXXX holds only four hex digits, so something like ๐Ÿ˜€ (code point U+1F600) is written as a surrogate pair: two escapes in a row, \ud83d\ude00. Both parsers turned that pair back into one character with code 1f600. You may instead write the emoji itself, as long as the file is UTF-8; the result is identical. The grammar also allows a lone surrogate such as \ud83d, and both Node and Python accepted it and produced a string holding the broken half (code d83d). RFC 8259 warns that software receiving such text behaves "unpredictably", so do not produce it, and do not trust that it survives being re-encoded as UTF-8.

Whitespace, Duplicate Keys and the Byte Order Mark

  • Whitespace. Space, tab, newline and carriage return are allowed between any two tokens, so a document can be one line or pretty-printed. Other whitespace is not allowed: a non-breaking space (U+00A0) before a value gave Unexpected token ' ', and a vertical tab after the value gave Unexpected non-whitespace character after JSON. This is the reason a document pasted from a web page or word processor sometimes fails with nothing visibly wrong.
  • Duplicate keys. {"a":1,"a":2} is accepted by the grammar, and both parsers returned a = 2 (the last one wins). RFC 8259 only says the names "SHOULD be unique" and that duplicates make the result "unpredictable", so another parser may keep the first or reject it. Treat duplicates as a bug in the data.
  • Byte order mark. A text file saved as "UTF-8 with BOM" starts with the invisible character U+FEFF. RFC 8259 says writers "MUST NOT add" it and parsers "MAY ignore" it. Both parsers I ran rejected it: Node with Unexpected token and the invisible character in the message, and Python with Unexpected UTF-8 BOM (decode using utf-8-sig). Save JSON as UTF-8 without BOM.

JSON Is Not a JavaScript Object

The name suggests JSON is "JavaScript objects as text". It began as a subset of the language, but today they are different things, and mixing them up is the cause of most of the forbidden-list errors above. I wrote one real JavaScript object and asked JSON.stringify to make JSON from it:

const lit = { name: 'Ada', 'x-y': 1, f() { return 1; }, u: undefined, n: NaN, i: -Infinity, d: new Date(0), nested: { ok: true }, }; console.log(JSON.stringify(lit));
{"name":"Ada","x-y":1,"n":null,"i":null,"d":"1970-01-01T00:00:00.000Z","nested":{"ok":true}}
JavaScript object literalJSON text
KeysUnquoted if a valid name; single, double or no quotesAlways double-quoted strings
StringsSingle, double or backtickDouble quotes only
Comments, trailing commasAllowedForbidden
ValuesFunctions, undefined, NaN, Infinity, dates, anythingOnly the six types; NaN, Infinity and undefined are gone, a function is dropped
What it isLive data inside a running programText, to be parsed

Look at what happened to the object: the method f and the property u vanished, NaN and -Infinity became null, and the date turned into a string. Serialising is lossy, and Chapter 2 maps every case. The point for now: copying a JavaScript literal into a .json file does not produce JSON. A file that works as JavaScript, such as { name: 'Ada', }, is rejected by every JSON parser.

Parsers Disagree

Because the standard leaves room, two correct parsers can give different answers for the same text. I ran twelve inputs through Node and Python and compared them side by side. Where the cell says differs, the two results were not the same:

InputNode 24.18Python 3.14
{"a":NaN}errorok, nandiffers
[Infinity]errorok, infdiffers
[1e400]ok, Infinityok, infsame
[12345678901234567890]ok, digits lostok, exactdiffers
[9007199254740993]ok, becomes ...992ok, exactdiffers
[-0]ok, -0ok, 0differs
[1.0]ok, 1ok, 1.0 (float)differs
{"a":1,"a":2}ok, 2ok, 2same
lone surrogateok, code d83dok, code d83dsame
surrogate pairok, code 1f600ok, code 1f600same
BOM firsterrorerrorsame
10,000 levels of [ nestingokoksame

Python's behaviour on NaN and Infinity is a documented extension, not a bug: the module accepts them by default, so a Python program can write JSON that a JavaScript program cannot read. Working with JSON 3 shows how to switch it off. Nesting depth is another place parsers differ: both of mine handled 10,000 levels, but I only tested those two, and other parsers set a lower limit and fail. If data crosses a language boundary, test it on both sides.

Reading a Parser's Error Message

Every message above has the same three parts: what it expected or found, where (a position, or a line and column) and sometimes a copy of the text. Three habits make them much more useful. First, the position is where the parser noticed, not always where the mistake is. In {"a":01} the real mistake is the leading zero at column 6, but both parsers complain about column 7, the 1 after the zero. Second, the same mistake is described differently in each language: Node and Python worded the single quote, the plus sign and the comment differently, so search for the position, not the words. Third, fix errors one at a time from the top, because everything after the first mistake is unreliable.

I took a file with six errors in it and fixed them in the order the parser reported them, printing each message with a caret under the position:

{ // the shop's config 'name': "Corner Shop", "open": True, "hours": [9, 17,], "discount": 010, "owner": {"first": "Ada" "last": "Lovelace"} }
round 0: line 2 col 3: Expecting property name enclosed in double quotes (the comment) round 1: line 2 col 3: Expecting property name enclosed in double quotes (the single quotes) round 2: line 3 col 11: Expecting value (True) round 3: line 4 col 18: Illegal trailing comma before end of array round 4: line 5 col 16: Expecting ',' delimiter (010) round 5: line 6 col 28: Expecting ',' delimiter (missing comma) round 6: valid

Rounds 0 and 1 give the same message at the same position, because after the first fix the next problem sits on the same spot. Python's message in round 0 does not mention comments at all; it says it wanted a property name, and a comment is simply not one. When a message seems to describe something else, look at that exact column.

Formatting and Checking JSON

  • Pretty-print in JavaScript: JSON.stringify(value, null, 2) indents with two spaces. It makes JSON from a value; to check a text, call JSON.parse(text) in the browser console or Node and see whether it throws.
  • From a terminal, with Python installed: py -m json.tool file.json (on Linux and macOS, python3 -m json.tool file.json) prints it re-indented (four spaces), or prints the error and exits with status 1. I ran it on a valid file and on one with a bad comma; the bad one printed Expecting value: line 2 column 11 (char 18) and exited 1. --compact prints it on one line, and the valid file came back as {"name":"Ada","tags":["x","y"],"n":{"k":null}}.
  • In VS Code: a file with the .json extension is checked as you type, with red marks and a Problems entry for errors. I did not run VS Code for this chapter, so I cannot show the messages. Some editors also accept comments in files they treat as “JSON with Comments” (JSONC); that is not JSON, and I did not check which of your files VS Code treats that way.
  • The command line, later: jq (Chapter 4) also reports an error with a line and column and a non-zero exit status.

Where This Fits

JSON is the usual body format for the HTTP APIs described in API Types & Design (REST and HTTP, chapters 2 to 4); how to send and inspect such requests is in API Testing & Tooling (cURL, chapters 6 and 7). This course stays on the data format itself.

Mistakes while preparing this chapter
1. My first escape tests were not testing JSON at all. I wrote \x41 and \u00e9 inside ordinary JavaScript strings, so JavaScript turned them into A and รฉ before JSON.parse ever saw them, and the "bad escape" case appeared to pass. How to avoid it: when testing a language's own escapes, build the text with String.fromCharCode(92) (a backslash) or String.raw, and print the text you are about to parse to check it still holds a backslash.

2. My first "negative zero" line printed 0. JSON.parse did keep -0; it was JSON.stringify(-0), which prints 0, that hid it. How to avoid it: check a value with Object.is(x, -0), not by printing a serialised copy.

3. I assumed the two parsers would agree on all twelve inputs. They disagreed on six. How to avoid it: when two systems share a format, run the same samples through both before you write "interoperable" in a design.

Hands-On Exercises

Exercise 1

Write a Node script with about twenty candidate texts (plain objects, top-level values, single quotes, unquoted keys, trailing commas, comments, undefined, NaN, leading zeros, hex, a raw tab, an empty string, duplicate keys). Write your prediction (valid or not) next to each, run JSON.parse on all of them, print the error message for each failure and count wrong predictions. Then flip one prediction on purpose to show the counter works.

๐Ÿ“„ View solution
Exercise 2

Parse the same twelve inputs (NaN, Infinity, 1e400, a 20-digit integer, 253 + 1, -0, 1.0, duplicate keys, a lone surrogate, a surrogate pair, a BOM, 10,000 levels of nesting) with Node and with Python, print one comparable line per input from each, and list the inputs on which the two languages disagree. Build the escape texts without writing a backslash inside a string literal.

๐Ÿ“„ View solution
Exercise 3

Take a file with six mistakes (a comment, single-quoted key, True, a trailing comma, a leading-zero number, a missing comma). Write a Python loop that parses it, prints the error line, column, message and a caret under the position, applies one fix and tries again until it is valid. Then run py -m json.tool on a valid and an invalid file and report the exit status of each.

๐Ÿ“„ View solution

Chapter 1 Quick Reference

  • JSON is text, defined by RFC 8259 and ECMA-404; media type application/json; UTF-8 when exchanged between systems; a whole document is one value (even 42 or "hi")
  • Six types: string, number, true/false, null, array, object (keys always double-quoted strings)
  • Forbidden: comments, trailing commas, single quotes, unquoted keys, undefined, leading zeros or plus sign or hex in numbers, NaN/Infinity (Python accepts them by default), raw control characters in strings, more than one value per document
  • Escapes: \" \\ \/ \b \f \n \r \t \uXXXX only; no \x41, no \U0041; characters above U+FFFF are a surrogate pair (\ud83d\ude00) or just written in UTF-8
  • Numbers have no size limit in the standard, but JavaScript keeps 15 to 17 digits: 12345678901234567890 became 12345678901234567000, and 253 + 1 became 253; Python kept both exactly
  • Duplicate keys: both parsers kept the last; the RFC says the result is unpredictable. A BOM at the start made both fail; save without it. Only space, tab, newline and carriage return count as whitespace
  • A JavaScript object literal is not JSON; JSON.stringify dropped functions and undefined and turned NaN and dates into null and strings
  • Error positions point where the parser noticed the problem; fix from the top, one at a time. Check a file with py -m json.tool file.json (exit status 1 on error)
Coming next
Chapter 2 is JSON in JavaScript: JSON.parse and JSON.stringify in depth, the indent, replacer and reviver arguments, toJSON, everything that silently disappears or changes on the way out, circular references, fetch and response.json(), and when structuredClone is the better copy. Start with Working with JSON 2.