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.
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:
| Type | Written as | Notes |
|---|---|---|
| string | "text" | Double quotes only; Unicode; escapes with a backslash |
| number | 42 -3.5 1e3 | One number type, written in decimal; no hex, no NaN, no Infinity |
| boolean | true false | Lower case only |
| null | null | Lower 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 wrote | Node 24.18 said | Python 3.14 said |
|---|---|---|
{"a":1 /* c */} a comment | Expected ',' or '}' after property value in JSON at position 7 (line 1 column 8) | Expecting ',' delimiter: line 1 column 8 (char 7) |
{"a":1,} trailing comma | Expected 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 comma | Unexpected token ']', "[1,2,]" is not valid JSON | Illegal trailing comma before end of array: line 1 column 5 (char 4) |
{'a':1} single quotes | Expected 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 key | Expected 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 JSON | Expecting value: line 1 column 6 (char 5) |
{"a":01} leading zero | Unexpected number in JSON at position 6 (line 1 column 7) | Expecting ',' delimiter: line 1 column 7 (char 6) |
{"a":+1} plus sign | Unexpected token '+', "{"a":+1}" is not valid JSON | Expecting value: line 1 column 6 (char 5) |
{"a":0x1F} hex number | Expected ',' 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 JSON | accepted: {'a': nan} |
| a raw tab or newline inside a string | Bad 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 values | Unexpected non-whitespace character after JSON at position 7 (line 1 column 8) | Extra data: line 1 column 8 (char 7) |
| an empty string | Unexpected end of JSON input | Expecting 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 text | Node (JavaScript number) | Python |
|---|---|---|
12345678901234567890 | 12345678901234567000 (digits lost) | 12345678901234567890 (int, exact) |
9007199254740993 (253 + 1) | 9007199254740992 (off by one) | 9007199254740993 (exact) |
1e400 | Infinity | inf |
-0 | -0 (kept; Object.is(x, -0) is true) | 0 (an int) |
1.0 | 1 (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:
| Write | Means | Code read back |
|---|---|---|
\" | double quote | 34 |
\\ | backslash | 92 |
\/ | forward slash (optional; / needs no escape) | 47 |
\b \f \n \r \t | backspace, form feed, newline, carriage return, tab | 8, 12, 10, 13, 9 |
\u00e9 | the 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 gaveUnexpected 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 returneda= 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 tokenand the invisible character in the message, and Python withUnexpected 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:
| JavaScript object literal | JSON text | |
|---|---|---|
| Keys | Unquoted if a valid name; single, double or no quotes | Always double-quoted strings |
| Strings | Single, double or backtick | Double quotes only |
| Comments, trailing commas | Allowed | Forbidden |
| Values | Functions, undefined, NaN, Infinity, dates, anything | Only the six types; NaN, Infinity and undefined are gone, a function is dropped |
| What it is | Live data inside a running program | Text, 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:
| Input | Node 24.18 | Python 3.14 | |
|---|---|---|---|
{"a":NaN} | error | ok, nan | differs |
[Infinity] | error | ok, inf | differs |
[1e400] | ok, Infinity | ok, inf | same |
[12345678901234567890] | ok, digits lost | ok, exact | differs |
[9007199254740993] | ok, becomes ...992 | ok, exact | differs |
[-0] | ok, -0 | ok, 0 | differs |
[1.0] | ok, 1 | ok, 1.0 (float) | differs |
{"a":1,"a":2} | ok, 2 | ok, 2 | same |
| lone surrogate | ok, code d83d | ok, code d83d | same |
| surrogate pair | ok, code 1f600 | ok, code 1f600 | same |
| BOM first | error | error | same |
10,000 levels of [ nesting | ok | ok | same |
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:
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, callJSON.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 printedExpecting value: line 2 column 11 (char 18)and exited 1.--compactprints 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
.jsonextension 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.
\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
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.
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.
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.
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 (even42or"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 \uXXXXonly; 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.stringifydropped functions andundefinedand turnedNaNand dates intonulland 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)
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.