Skip to content

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.

import json
json.dumps((1, 2))                  # '[1, 2]'
json.loads(json.dumps({1: 'a'}))    # {'1': 'a'}

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

  1. Take a dict of yours with int keys, or tuple values, through json.loads(json.dumps(d)) and compare with ==. Section 6 says which two shapes come back different.
  2. Dump a record that holds a datetime or a Decimal. Read which one raises first, then add default=str and look at what arrived: a string, in both cases.
  3. Read a JSON file you did not write with object_pairs_hook set to a function that raises on a repeated key. Section 5 says the default would have kept the last one and told nobody.
  4. Write the same data twice, once with ensure_ascii=False, and compare the file sizes on text with accents in it.
  5. grep your JSON outputs for NaN and Infinity. Section 3 says Python wrote them without complaint, and a consumer in another language will not read them.

See also