json is not Python¶
Level: 201 · for Python programmers
One line: json.dumps() writes a language with six types, so a tuple goes out as a list, an int key comes back as a str, a set, bytes, a Decimal and a date have no spelling at all, NaN goes out as a token JSON does not have, and 'Łódź' leaves as "Łódź" unless you say otherwise — each of them without an error, except the four that refuse.
JSON has an object, an array, a string, a number, true, false and null, and nothing else. On the way out json.dumps() maps Python onto those: dict to object, list and tuple to array, str to string, int and float to number, the two booleans and None to the three words, and every other type to TypeError unless a default= function says what to write instead. On the way back json.loads() has only the six to work from, so it produces exactly dict, list, str, int or float, bool and None. Never a tuple, never a key that is not a string.
So a round trip through JSON is a projection, not an identity, and the page is the list of places where something falls out: the type of a container, the type of a key, the value of a float that has no JSON spelling, and the spelling of any character past ASCII.
The run¶
Verified output of json_is_not_python_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. THE SPELLINGS CHANGE ON THE WAY OUT, AND DO NOT CHANGE BACK
json.dumps(True) 'true' true, not True
json.dumps(None) 'null'
json.dumps((1, 2)) '[1, 2]' a tuple goes out as a list
json.loads(json.dumps((1, 2))) [1, 2] and comes back as one
json.dumps({1: 'a'}) '{"1": "a"}' an int key becomes a string key
json.loads(json.dumps({1: 'a'})) {'1': 'a'} and stays one
json.dumps({True: 1, None: 2}) '{"true": 1, "null": 2}'
json.dumps({(1, 2): 'a'}) TypeError a tuple key has no JSON spelling at all
2. NOT EVERY VALUE HAS A SPELLING
json.dumps(b'x') TypeError bytes: decode first, or base64
json.dumps({1, 2}) TypeError a set is not a list until you make it one
json.dumps(Decimal('0.1')) TypeError
json.dumps(datetime.date(2026, 9, 22)) TypeError
json.dumps(datetime.date(2026, 9, 22), default=str) '"2026-09-22"' default= is asked for anything it cannot spell
3. THE NUMBERS
json.dumps(0.1) '0.1' repr(): the shortest string that reads back
json.dumps(1e16) '1e+16'
json.dumps(10**20) '100000000000000000000' an int of any size goes out exactly
json.loads('100000000000000000000') 100000000000000000000 and reads back exactly; JavaScript rounds it
json.loads('1e400') inf too big for a double: inf, and no error
json.dumps(float('nan')) 'NaN' NaN is not JSON: RFC 8259 has no such token
json.dumps(float('nan'), allow_nan=False) ValueError
json.loads('NaN') nan yet the reader accepts it
json.loads('01') JSONDecodeError a leading zero is not a JSON number
4. THE TEXT
json.dumps('Łódź') '"\\u0141\\u00f3d\\u017a"' ensure_ascii: every non-ASCII character escaped
json.dumps('Łódź', ensure_ascii=False) '"Łódź"'
json.loads(json.dumps('Łódź')) == 'Łódź' True both spellings read back to the same str
json.loads(b'[1]') [1] bytes are accepted: the encoding is sniffed
5. THE KEYS
json.loads('{"a": 1, "a": 2}') {'a': 2} a duplicate key: the last one wins, silently
json.dumps({'b': 1, 'a': 2}) '{"b": 1, "a": 2}' insertion order is kept
json.dumps({'b': 1, 'a': 2}, sort_keys=True) '{"a": 2, "b": 1}' unless you ask for sorted
6. WHAT SURVIVES A ROUND TRIP
value json.dumps(value) json.loads(...) equal?
1 1 1 True
1.0 1.0 1.0 True
True true True True
None null None True
'a' "a" 'a' True
[1] [1] [1] True
(1,) [1] [1] False
{'k': 1} {"k": 1} {'k': 1} True
{1: 'a'} {"1": "a"} {'1': 'a'} False
What the run shows¶
Section 1: the spellings change on the way out, and do not change back. True is true and None is null. A tuple goes out as [1, 2] and comes back as the list [1, 2], because JSON has no tuple for it to come back as. A dict with the key 1 goes out as {"1": "a"}, because a JSON object's keys are strings, and it comes back with the string key '1'; True and None as keys become "true" and "null" the same way. A tuple key has no string spelling, so that one raises TypeError rather than guessing.
Section 2: not every value has a spelling. bytes, a set, a Decimal and a date all raise TypeError. default=str is the escape hatch: dumps calls it for any object it cannot spell and writes whatever it returns, so the date leaves as "2026-09-22". That is a string, not a date, and a Decimal sent through the same door arrives as a string of digits, not a number; parse_float=Decimal on the way back in is the other half of keeping money exact.
Section 3: the numbers. A float is written with repr(), the shortest string that reads back to the same double, so 0.1 is 0.1 and 1e16 is 1e+16. An int of any size goes out exactly and reads back exactly, where JavaScript's reader would round 10**20 to the nearest double; Comparing an int with a float has that as its first exercise. The other direction is quieter: json.loads('1e400') is inf, with no error, because that is what float('1e400') is. NaN is not JSON at all, and dumps writes it anyway, as the token NaN, which another language's parser will refuse; allow_nan=False turns that into a ValueError at your end instead. The reader accepts NaN too. 01 is not a JSON number, and that one raises JSONDecodeError, which is a ValueError.
Section 4: the text. By default every character outside ASCII is written as a \u escape, so 'Łódź' leaves as "Łódź", four characters as twenty-one; ensure_ascii=False writes the characters themselves. Both read back to the same str, which is why the default is safe and merely large. json.loads also accepts bytes, and works out whether they are UTF-8, UTF-16 or UTF-32 from the first four of them, which is the one place in the standard library that sniffs an encoding for you.
Section 5: the keys. A document with the same key twice is not an error: the last value wins, silently. dumps writes a dict's keys in insertion order and sorts them only when asked, so two equal dicts can produce two different documents, and sort_keys=True is what makes the text of a dict canonical.
Section 6: what survives a round trip. Seven of the nine values come back == to what went in. The two that do not are the ones sections 1 said: a tuple comes back a list, and an int key comes back a str. 1.0 survives because it was written with its point and read back as a float.
If you are coming from Rust¶
serde_json reads a document into a type, and the type is the schema: from_str::<(i32, i32)> turns [1, 2] back into a tuple, a HashMap<u32, String> gets its integer keys back because the field says they are integers, and a document that does not fit is an error rather than a guess. Python's loads has no type to aim at, so it can only produce the six, and everything in section 6 that did not survive is a place where the missing type would have decided. The Rust library's JSON round trip ↗ is that side, and serde derive ↗ is where the schema comes from.
If you are coming from C¶
C has no JSON in its standard library, and a C program that speaks it is doing by hand what section 4 does for you, byte by byte, including the escapes; the C library's Bytes on the wire ↗ is where those bytes are looked at. Python's json module reads and writes str, and the bytes are made by the file's encoding at the edge, which is Opening a file.
If you are coming from ABAP¶
ABAP reads JSON into typed structures and internal tables, through CALL TRANSFORMATION with the id transformation, /ui2/cl_json or xco_cp_json, so the type is the schema as it is in Rust. Two consequences differ from Python's. A structure field always holds a value, so JSON null has nowhere to go and a missing key is indistinguishable from an initial one; and the field names are the structure's, uppercase, so a camelCase document needs a name mapping that Python's dict never asks for. (Not machine-checked — CI cannot run ABAP.)
Try it¶
- Take a dict of yours with
intkeys, or tuple values, throughjson.loads(json.dumps(d))and compare with==. Section 6 says which two shapes come back different. - Dump a record that holds a
datetimeor aDecimal. Read which one raises first, then adddefault=strand look at what arrived: a string, in both cases. - Read a JSON file you did not write with
object_pairs_hookset to a function that raises on a repeated key. Section 5 says the default would have kept the last one and told nobody. - Write the same data twice, once with
ensure_ascii=False, and compare the file sizes on text with accents in it. grepyour JSON outputs forNaNandInfinity. Section 3 says Python wrote them without complaint, and a consumer in another language will not read them.
See also¶
- String literals: the
\uescape section 4 writes, and why it is four hex digits - Encode and decode and Opening a file: where the
strthis module produces becomes bytes - Comparing an
intwith afloatand Float equality and NaN: the big integer that JavaScript rounds, and the NaN that has no JSON spelling json↗ in the library reference, in particular its notes on character encodings and on infinite and NaN values, and RFC 8259 ↗, which is the language- JSON round trip ↗ and
serdederive ↗ in the Rust library