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.

Every example here was run, except where marked
Python 3.14.0 on Windows 11, with the standard-library 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

FunctionFromTo
json.loads(s)a string (or bytes)a Python value
json.dumps(obj)a Python valuea string
json.load(f)an open filea Python value
json.dump(obj, f)a Python valuewritten 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:

import json d = {"name": "Ada", "age": 36, "tags": ["a", "b"], "ok": True, "x": None} s = json.dumps(d) print(s) print(json.loads(s) == d)
{"name": "Ada", "age": 36, "tags": ["a", "b"], "ok": true, "x": null} True

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

json.dumps(d, indent=2) # pretty-printed, 2 spaces json.dumps({"b": 1, "a": 2, "é": "ü"}, sort_keys=True) json.dumps({"é": "ü😀"}) # default: ensure_ascii=True json.dumps({"é": "ü😀"}, ensure_ascii=False) json.dumps(d, separators=(',', ':')) # compact, like JavaScript
{"a": 2, "b": 1, "\u00e9": "\u00fc"} {"\u00e9": "\u00fc\ud83d\ude00"} {"Γ©": "ΓΌπŸ˜€"} {"name":"Ada","age":36,"tags":["a","b"],"ok":true,"x":null}
  • indent: a number of spaces or a string (I tried '\t' for tabs). With indent=0 you 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. False keeps 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 (',', ': ') when indent is 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

JSONPython (loads)Python (dumps) accepts
objectdictdict (with str, int, float, bool or None keys)
arraylistlist and tuple
stringstrstr
number (integer form)int (any size)int
number (fraction or exponent)floatfloat
true / falseTrue / FalseTrue / False
nullNoneNone

Everything not in the right-hand column raises TypeError. I ran each of these through dumps then loads and compared with the original:

value dumps loads(dumps) == original ? dict, str keys '{"a": 1, "b": [true, null, 2.5]}' True tuple '[1, 2]' False -> [1, 2] (a list) dict, int keys '{"1": "a", "2": "b"}' False -> {'1': 'a', '2': 'b'} dict, float key '{"2.5": "x"}' False dict, bool/None key '{"true": "x", "null": "y"}' False int 10**20 '100000000000000000000' True float -0.0 '-0.0' True nan 'NaN' both are NaN (nan != nan, so == is always False) inf 'Infinity' True date, Decimal, set, bytes TypeError (no default for them) key collision '{"1": "int", "1": "str"}' False -> {'1': 'str'}

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:

import json, dataclasses, datetime as dt, uuid, pathlib from decimal import Decimal @dataclasses.dataclass class Line: sku: str price: Decimal added: dt.date def default(o): if isinstance(o, (dt.datetime, dt.date)): return o.isoformat() if isinstance(o, Decimal): return str(o) # a string keeps every digit if dataclasses.is_dataclass(o): return dataclasses.asdict(o) if isinstance(o, (set, frozenset)): return sorted(o) raise TypeError('Object of type ' + type(o).__name__ + ' is not JSON serializable') order = {'id': 1001, 'placed': dt.datetime(2026, 10, 12, 8, 30, tzinfo=dt.timezone.utc), 'lines': [Line('A1', Decimal('19.99'), dt.date(2026, 1, 2))], 'tags': {'rush', 'gift'}} print(json.dumps(order, default=default, indent=2))
{ "id": 1001, "placed": "2026-10-12T08:30:00+00:00", "lines": [ { "sku": "A1", "price": "19.99", "added": "2026-01-02" } ], "tags": [ "gift", "rush" ] }

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.

Why a Decimal goes out as a string
A float 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.

def hook(o): if 'placed' in o: o['placed'] = dt.datetime.fromisoformat(o['placed']) if 'added' in o: o['added'] = dt.date.fromisoformat(o['added']) if 'price' in o: o['price'] = Decimal(o['price']) return o back = json.loads(text, object_hook=hook)
placed datetime.datetime(2026, 10, 12, 8, 30, tzinfo=datetime.timezone.utc) == original: True line 0 {'sku': 'A1', 'price': Decimal('19.99'), 'added': datetime.date(2026, 1, 2)} tags is now a list (a set cannot come back by itself)

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)) gave 0.3, and without it 0.30000000000000004. This is the way to read money from JSON as numbers without any rounding error. (parse_int works the same way for integers; parse_int=str turned 1 and 3 into strings and left 2.0 a float.)
  • parse_constant: called for NaN, Infinity and -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 with duplicate key: 'a', and the same hook accepted clean input. Passing object_pairs_hook=list returned 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:

try: json.dumps([float('nan')], allow_nan=False) except ValueError as e: print('dumps:', e) def refuse(name): raise ValueError('refusing ' + name) try: json.loads('[NaN]', parse_constant=refuse) except ValueError as e: print('loads:', e)
dumps: Out of range float values are not JSON compliant: nan loads: refusing NaN

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:

e.msg = Expecting value | e.lineno = 2 | e.colno = 7 | e.pos = 15 | e.doc[e.pos] = '}' | isinstance(e, ValueError) = True

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 didWhat happened
open(p, encoding='utf-8') to write and to readRound 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=TrueWorks: 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 WindowsLine 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":"Γ©"}}:

py -m json.tool t.json 4-space indent (the default), non-ASCII escaped as \u00e9 py -m json.tool --sort-keys t.json keys in alphabetical order py -m json.tool --indent 1 t.json one-space indent py -m json.tool --tab t.json tabs py -m json.tool --compact t.json {"b":1,"a":{"z":"\u00e9"}} py -m json.tool --no-ensure-ascii --compact t.json {"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 outputCompact: {"a":[1,2]}Spaces: {"a": [1, 2]}
Non-ASCII charactersWritten as they areEscaped as \uXXXX unless ensure_ascii=False
NaN / InfinityBecome null when writing; rejected when readingWritten and read as NaN / Infinity unless allow_nan=False / parse_constant
Unknown typeDropped, or {}, silentlyTypeError unless default= handles it
Big integersLose digits past 253Exact (to the 4,300-digit parse limit)
Hook on readingReviver (key, value; inside-out)object_hook (whole dict; inside-out)
Hook on writingtoJSON and replacerdefault= or a JSONEncoder subclass
Key orderInteger-like keys first, sortedInsertion order (sort_keys=True to sort)
Circular dataTypeError: Converting circular structureValueError: 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.

Mistakes while preparing this chapter
1. My first test script crashed printing an emoji. The Windows console encoding (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

Exercise 1

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.

πŸ“„ View solution
Exercise 2

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.

πŸ“„ View solution
Exercise 3

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.

πŸ“„ View solution

Chapter 3 Quick Reference

  • loads/dumps work on strings (loads also takes bytes), load/dump on open files; the encoding is chosen by open(), so always write encoding='utf-8'
  • dumps options: indent, sort_keys, separators=(',', ':') for compact, ensure_ascii=False to keep non-ASCII; default output has spaces and escapes non-ASCII as \uXXXX
  • Mapping: dict/list/str/int/float/True/False/None. tuple becomes a list; non-string keys become strings and can collide silently; date, Decimal, set, bytes, complex and plain objects raise TypeError
  • default= (or a JSONEncoder subclass) 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=False makes dumps raise, parse_constant can make loads raise; 1e400 still becomes inf
  • JSONDecodeError (a ValueError) has msg, doc, pos, lineno, colno; a 4,301-digit integer raises a plain ValueError (limit 4,300)
  • Windows pitfalls: open() with no encoding read UTF-8 as cp1252 without an error; a BOM needs utf-8-sig; text mode writes CRLF unless newline='\n'
  • py -m json.tool: --sort-keys, --indent N, --tab, --compact, --no-ensure-ascii (also --no-indent, --json-lines, not run)
Coming next
Chapter 4 is the 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.