JSON in Python
Working with JSON
Chapter 3 Β· Parsing and Generating in Python
Python's standard library has a json module with the same four jobs as JavaScript's two functions: text to values, values to text, and the same for files. Its defaults differ from JavaScript's in ways that
matter: it adds spaces, it escapes non-ASCII characters, it accepts NaN, and it turns tuples and integer keys into things that do not come back the same. This chapter walks through the module, shows what
survives a round trip and what does not (measured), then covers hooks for dates, Decimal and dataclasses, and the one place Python bites on Windows: reading and writing files without saying which encoding.
It builds on Working with JSON 1 (the format's rules) and Working with JSON 2 (the JavaScript side to compare with); basic Python comes from Python Fundamentals.
json module only. The Windows console and file defaults matter in this chapter (the machine's default text encoding is cp1252); on Linux
and macOS the default is normally UTF-8, so the encoding problems below may not appear there. Not run: Linux, macOS, other Python versions, and the third-party libraries named in the last section
(not installed, so no timing is claimed).
The Four Functions
| Function | From | To |
|---|---|---|
json.loads(s) | a string (or bytes) | a Python value |
json.dumps(obj) | a Python value | a string |
json.load(f) | an open file | a Python value |
json.dump(obj, f) | a Python value | written into an open file |
The "s" in loads and dumps stands for string, a common source of confusion with load and dump, which take files. I round-tripped a dictionary:
Notice what changed on the way: True became true and None became null, and there is a space after every : and ,. JavaScript's
stringify prints {"a":1} with no spaces. Both are valid JSON; the difference only matters for size and for tests that compare strings. The loads function also accepts
bytes: I passed UTF-8 bytes and UTF-16 bytes and it detected each encoding and parsed both.
Formatting Options for dumps
indent: a number of spaces or a string (I tried'\t'for tabs). Withindent=0you still get newlines, one item per line, but no indentation.sort_keys=True: keys in alphabetical order, which makes output stable for diffs and tests. Unlike JavaScript, Python keeps insertion order by default and never reorders numeric-looking keys.ensure_ascii:True(the default) turns every non-ASCII character into\uXXXX, with a surrogate pair for π (\ud83d\ude00). The output is pure ASCII, safe in any file or pipe, but unreadable for non-English text and about 6 characters per accented letter.Falsekeeps the characters, and then you must write the result in UTF-8 (see the file section).separators: a pair(item_separator, key_separator). The default is(', ', ': ')normally and(',', ': ')whenindentis set (so no trailing spaces at line ends).(',', ':')is the compact form.
Comparing the two languages' floats on the same numbers, I found Python and JavaScript mostly agree but not always in how they write them: 1e-7 is 1e-07 in Python and 1e-7 in
JavaScript; 1.0 stays 1.0 in Python but is 1 in JavaScript; 1e16 is 1e+16 in Python and 10000000000000000 in JavaScript. All of these are valid JSON
and parse to the same numbers.
The Type Mapping, and What Does Not Round-Trip
| JSON | Python (loads) | Python (dumps) accepts |
|---|---|---|
| object | dict | dict (with str, int, float, bool or None keys) |
| array | list | list and tuple |
| string | str | str |
| number (integer form) | int (any size) | int |
| number (fraction or exponent) | float | float |
true / false | True / False | True / False |
null | None | None |
Everything not in the right-hand column raises TypeError. I ran each of these through dumps then loads and compared with the original:
Three results deserve attention. Tuples come back as lists and every non-string key comes back as a string, so a dictionary keyed by integers is not the same dictionary after a round trip.
Worse, the key collision case produced invalid-looking output: {1: 'int', '1': 'str'} became {"1": "int", "1": "str"}, with a duplicate key, and reading it back kept only the last.
Python had two different keys, JSON has one. Nothing raised an error. The other failures are loud: TypeError: Object of type set is not JSON serializable (the same message for datetime,
bytes, complex and plain objects), and TypeError: keys must be str, int, float, bool or None, not tuple for a tuple key. skipkeys=True silently drops such keys, which
is rarely what you want.
A circular dictionary raised ValueError: Circular reference detected, the counterpart of the JavaScript error from Chapter 2. Python integers have no size limit, so 10**20 round-trips exactly,
unlike in JavaScript. There is one limit, though, added to Python for safety: converting a very long digit string to an int is capped. I parsed a 4,300-digit number fine, and a 4,301-digit one failed with
ValueError: Exceeds the limit (4300 digits) for integer string conversion (the limit is sys.get_int_max_str_digits()). Ordinary data never gets near it, but an attacker's input can, which is
why the limit exists.
Teaching dumps New Types: default=
A default function is called for every object the encoder does not know. It should return something the encoder does know, or raise TypeError. Here is one that handles the types most
programs have, tested on a dictionary holding every one of them at once:
Details worth knowing. dataclasses.asdict converts nested dataclasses for you, and then default is called again for the Decimal and date inside. A set has
no order, so sort it to make the output repeatable. pathlib paths, uuid.UUID and enum.Enum values work the same way: I tested PurePosixPath('/tmp/x') (via
.as_posix()), a UUID (via str) and an Enum (via .value) with a similar function and each produced a plain string. If you prefer a class, subclass
json.JSONEncoder, override its default method, and pass cls=MyEncoder; I confirmed that gave the same output.
Decimal goes out as a stringfloat cannot hold 0.1 exactly: 0.1 + 0.2 printed 0.30000000000000004, while Decimal('0.1') + Decimal('0.2') printed 0.3. Converting the
Decimal to float before writing brings the error back, and I found no simple built-in way in the standard module to write a Decimal as a bare JSON number. A string keeps every digit. The price of that choice:
the reader must know that field is a decimal string. Chapter 6 (JSON Schema) is where such a contract is written down.
Teaching loads New Types: Hooks
Python has four hooks on loads. object_hook is called with each decoded dictionary, innermost first (like the JavaScript reviver). I logged the order for
{"a":{"b":{}}, "c":[{"d":1}]}: the hook saw [] (the innermost empty object), then ['b'], then ['d'], then ['a', 'c'], the outermost, last.
The hook rebuilt the datetime, the date and the Decimal, and the comparison with the original was True. Like the JavaScript reviver, it matches by key name: a string such as
"2026-10-12 is a date" under another key was left alone in my test, but any key called price at any depth is converted. The tags set came back as a list, because JSON has
no way to say "this array is a set"; convert it yourself if you need one.
The other three hooks, all measured:
parse_float=Decimal: every number with a fraction or exponent is created with that function.sum(json.loads('[0.1,0.2]', parse_float=Decimal))gave0.3, and without it0.30000000000000004. This is the way to read money from JSON as numbers without any rounding error. (parse_intworks the same way for integers;parse_int=strturned1and3into strings and left2.0a float.)parse_constant: called forNaN,Infinityand-Infinity. Raising inside it rejects them (next section).object_pairs_hook: called with the list of (key, value) pairs before they become a dictionary, so it can see duplicates.json.loads('{"a":1,"a":2}')normally returned{'a': 2}, but a small hook that raises on a repeated key made it fail withduplicate key: 'a', and the same hook accepted clean input. Passingobject_pairs_hook=listreturned the pairs themselves in file order.
NaN and Infinity: Python's Extension
As Chapter 1 showed, Python accepts NaN, Infinity and -Infinity by default, and writes them too: json.dumps([float('nan'), float('inf'), -float('inf')]) gave
[NaN, Infinity, -Infinity], which no standard-conforming parser (including JavaScript's) will read. To keep your output portable, switch it off in both directions:
allow_nan=False makes dumps raise instead of writing invalid JSON; for loads, the way to refuse is parse_constant. Note that 1e400, which is a legal
JSON number, still parses to inf, so a value can become infinite without the word appearing. If you must handle NaN in your data, decide on a replacement (usually null) and
convert before writing.
Reading Errors: JSONDecodeError
Bad input raises json.JSONDecodeError, a subclass of ValueError, so catching ValueError catches it too. It carries the details as attributes. For
'{"a": 1,\n "b": }' I read:
pos is a character offset into doc (the original text), counted from 0; lineno and colno count from 1. They are what Chapter 1's exercise used to put a caret under the
error. Keep in mind that the 4,301-digit failure above is a plain ValueError, not a JSONDecodeError.
Files, Encodings and Line Endings (Where Windows Bites)
json.load and json.dump work on files you open yourself, which means the encoding is chosen by open(), not by the json module. On this machine
locale.getpreferredencoding(False) is cp1252, and the results are instructive. I wrote a file containing ZoΓ« and π in UTF-8, then read it back several ways:
| What I did | What happened |
|---|---|
open(p, encoding='utf-8') to write and to read | Round trip equal: True |
open(p) to read that UTF-8 file (no encoding) | No error, wrong text: 'Zo\xc3\xab' and '\xf0ΕΈΛβ¬' (mojibake) |
open(p, 'w') to write with ensure_ascii=False (no encoding) | UnicodeEncodeError (cp1252 has no π) |
open(p, 'w') to write with the default ensure_ascii=True | Works: pure ASCII, {"name": "Zo\u00eb", "emoji": "\ud83d\ude00"} |
read a file that starts with a UTF-8 BOM, encoding='utf-8' | JSONDecodeError: Unexpected UTF-8 BOM (decode using utf-8-sig) |
same file, encoding='utf-8-sig' | {'a': 1} (the BOM is stripped) |
write with indent in text mode on Windows | Line endings are \r\n (CRLF); newline='\n' in open() gave \n |
The dangerous row is the second: reading UTF-8 JSON with the wrong encoding does not fail, it quietly produces garbage. The cure is to always write encoding='utf-8' in
open(), which RFC 8259 also requires for JSON exchanged between systems. The default ensure_ascii=True is the reason many programs have never seen the problem: ASCII-only output reads
the same in every encoding. The same file written with CRLF line endings is still valid JSON (newlines are whitespace), but it produces noisy diffs in a Git repository that expects LF.
The Command Line: python -m json.tool
The module can be run as a tool to check and format a file (as in Chapter 1). Python 3.14's options, from its --help, and my run of each on {"b":1,"a":{"z":"Γ©"}}:
The help also lists --no-indent and --json-lines (parse JSON Lines input); I did not run those two, and I did not test older Pythons, which may offer fewer options.
Side by Side with JavaScript
| JavaScript (Chapter 2) | Python | |
|---|---|---|
| Default output | Compact: {"a":[1,2]} | Spaces: {"a": [1, 2]} |
| Non-ASCII characters | Written as they are | Escaped as \uXXXX unless ensure_ascii=False |
| NaN / Infinity | Become null when writing; rejected when reading | Written and read as NaN / Infinity unless allow_nan=False / parse_constant |
| Unknown type | Dropped, or {}, silently | TypeError unless default= handles it |
| Big integers | Lose digits past 253 | Exact (to the 4,300-digit parse limit) |
| Hook on reading | Reviver (key, value; inside-out) | object_hook (whole dict; inside-out) |
| Hook on writing | toJSON and replacer | default= or a JSONEncoder subclass |
| Key order | Integer-like keys first, sorted | Insertion order (sort_keys=True to sort) |
| Circular data | TypeError: Converting circular structure | ValueError: Circular reference detected |
Faster Third-Party Libraries (Names Only)
The standard module is fast enough for most work. Libraries that advertise being faster exist; three that are widely used are orjson, ujson and msgspec. I looked up their latest
versions on PyPI today (orjson 3.13.0, ujson 6.0.0, msgspec 0.22.0) but did not install, test or time any of them, so this chapter makes no speed claim and no claim about how they differ from json
on the edge cases above. Measure on your own data before switching, and run this chapter's round-trip table against any replacement, since libraries make different choices about big integers, NaN and key types.
cp1252) could not show π, and Python raised UnicodeEncodeError from print, not from
json. How to avoid it: print with ascii(value) when you want to see exactly what a string holds, or run with py -X utf8.2. Running with
-X utf8 then hid the encoding problem. With UTF-8 mode on, open() with no encoding read my file correctly, so my first "no encoding" test showed no problem at all.
How to avoid it: reproduce an environment-dependent bug in the environment's default settings, and print locale.getpreferredencoding(False) so the reader knows what was in force.3. My first "money arithmetic" example used
19.99 + 0.10 + 0.20 and showed no error. That sum happens to print as 20.29 in floating point, so the example demonstrated nothing.
How to avoid it: find an input that actually fails before using it to teach: 0.1 + 0.2 does.
Hands-On Exercises
Write a script that takes about sixteen Python values (a dict with string keys, a tuple, dicts with int, float and bool/None keys, 10**20, -0.0, NaN, infinity, a string with an accented letter and an emoji, a date, a Decimal, a set, bytes, and a dict whose keys 1 and '1' collide). For each, run json.dumps, then json.loads, and print the JSON text, whether the result equals the original, and what came back, with a clear marker for values that cannot be serialised. Handle NaN's "never equal to itself" correctly.
Take an order containing a datetime, a list of dataclass lines each with a Decimal and a date, and a set of tags. Show that plain dumps fails, write a default function and print the indented JSON, then write an object_hook that restores the datetime, dates and Decimals and check one value with ==. Then show 0.1 + 0.2 read with and without parse_float=Decimal, and write an object_pairs_hook that rejects duplicate keys, testing it on both a duplicate and a clean document.
In a temporary folder, print the default text encoding, then write a JSON file containing "ZoΓ«" and an emoji with encoding='utf-8' and read it back. Show what happens when you read it with no encoding, and when you write with no encoding both with and without ensure_ascii=False. Then test a file with a BOM using utf-8 and utf-8-sig, compare line endings with and without newline='\n', show the allow_nan=False and parse_constant refusals, and run json.tool with six option combinations. Do not use -X utf8 for the first part.
Chapter 3 Quick Reference
loads/dumpswork on strings (loadsalso takes bytes),load/dumpon open files; the encoding is chosen byopen(), so always writeencoding='utf-8'dumpsoptions:indent,sort_keys,separators=(',', ':')for compact,ensure_ascii=Falseto keep non-ASCII; default output has spaces and escapes non-ASCII as\uXXXX- Mapping: dict/list/str/int/float/True/False/None.
tuplebecomes a list; non-string keys become strings and can collide silently; date, Decimal, set, bytes, complex and plain objects raiseTypeError default=(or aJSONEncodersubclass) handles new types on the way out:isoformat()for dates,str()for Decimal,dataclasses.asdict,sorted()for sets- On the way in:
object_hook(each dict, innermost first),parse_float=Decimal,parse_int,parse_constant,object_pairs_hook(sees duplicate keys) - NaN and Infinity are accepted and written by default;
allow_nan=Falsemakesdumpsraise,parse_constantcan makeloadsraise;1e400still becomesinf JSONDecodeError(aValueError) hasmsg,doc,pos,lineno,colno; a 4,301-digit integer raises a plainValueError(limit 4,300)- Windows pitfalls:
open()with no encoding read UTF-8 as cp1252 without an error; a BOM needsutf-8-sig; text mode writes CRLF unlessnewline='\n' py -m json.tool:--sort-keys,--indent N,--tab,--compact,--no-ensure-ascii(also--no-indent,--json-lines, not run)
jq command line: installing it (it is not on this machine yet, so the chapter starts with a checked install on Windows), the program-on-stdin model, .key, .[],
pipes, select and map, the -r, -c, -s and --arg options, curl | jq, and quoting in bash, PowerShell and cmd. Start with
Working with JSON 4.