JSON in Python

json.dumps escapes non-ASCII by default, accepts NaN in violation of the spec, and silently merges the keys True and 1. Four defaults worth changing.

Every snippet and output below was executed on Python 3.13.

1. Non-ASCII is escaped by default

json.dumps({"name": "한글 🎉", "n": 1})
# '{"name": "\ud55c\uae00 \ud83c\udf89", "n": 1}'     45 bytes

json.dumps({"name": "한글 🎉", "n": 1}, ensure_ascii=False)
# '{"name": "한글 🎉", "n": 1}'                          24 bytes

Nearly double the size for that field, and unreadable in a log or a diff. The default belongs to a time when transports could not be trusted with UTF-8; nothing speaking HTTP has that problem now. Set ensure_ascii=False, and when writing to a file pass encoding="utf-8" too, or the write will fail on a Windows default locale.

2. Large integers survive — unlike in JavaScript

json.loads('{"id": 9007199254740993, "v": 12345678901234567890}')
# {'id': 9007199254740993, 'v': 12345678901234567890}   ← exact

Python integers have arbitrary precision, so both values round-trip unchanged. This is genuinely better than the JavaScript behaviour, where the same document loses digits silently.

The asymmetry is the hazard. A payload that passes through a Python service intact can be corrupted by a Node one, and neither raises. In a mixed system the convention still has to be strings for anything whose exact digits matter.

3. NaN and Infinity are accepted, which is not JSON

json.dumps({"a": float("nan"), "b": float("inf")})
# '{"a": NaN, "b": Infinity}'        ← not valid JSON

json.loads('{"a": NaN}')
# {'a': nan}                          ← also accepted

json.dumps({"a": float("nan")}, allow_nan=False)
# ValueError

RFC 8259 has no production for either token, so a strict parser rejects the document — including JavaScript's JSON.parse. What Python emits here is not interchangeable JSON despite coming from a JSON library, and it will fail at whatever boundary it eventually crosses.

Pass allow_nan=False at any boundary where the consumer is not also Python. It turns a silent incompatibility into a ValueError at the point of serialisation, which is where you can still do something about it.

4. Dictionary keys are coerced, and one case loses data

json.dumps({(1, 2): "x"})
# TypeError                    ← tuple keys refused

json.dumps({1: "a", True: "b", None: "c"})
# '{"1": "b", "null": "c"}'    ← where did 1: "a" go?

Integers, floats, booleans and None are quietly converted to strings; only tuples raise. The third line is the one to notice. In Python True == 1 and both hash the same, so the two entries were already a single key in the dictionary before serialisation ever ran — True overwrote 1, and the JSON output is simply reporting that.

Nothing raises at any point. If your keys are not already strings, convert them yourself so the collapse happens where you can see it.

Duplicate keys, and how to see them

json.loads('{"a":1,"a":2}')
# {'a': 2}                                        ← last wins, no warning

json.loads('{"a":1,"a":2}', object_pairs_hook=lambda ps: [k for k, _ in ps])
# ['a', 'a']                                      ← now visible

object_pairs_hook receives the pairs in document order before they collapse into a dictionary, which is the only place the duplication still exists. Collect the keys, compare against a set, and raise on a repeat if the document comes from somewhere you do not control. The background on why parsers disagree here is in JSON vs JSON5.

Frequently asked questions

Why does json.dumps turn Korean text into \u escapes?

Because ensure_ascii defaults to True, so every character above ASCII is expanded into a \u sequence. We measured the cost on a short dictionary containing "한글 🎉": 45 bytes with the default against 24 with ensure_ascii=False, so nearly double for that field. The default dates from an era when transports could not be trusted with UTF-8, which is no longer the situation for anything speaking HTTP. Pass ensure_ascii=False unless something downstream genuinely requires ASCII, and remember to write the file with encoding="utf-8" when you do, or the write itself will fail on Windows, where the default locale encoding is not UTF-8 and the error arrives at write time rather than at serialisation. The output is still valid JSON either way; only its size and readability change.

Does Python lose precision on large JSON integers?

No, and this is where Python behaves better than JavaScript rather than worse. Python integers have arbitrary precision, so json.loads reads 9007199254740993 and 12345678901234567890 exactly, and dumping them again reproduces the original digits — we verified both round trips. JavaScript rounds the same values silently because it parses every number into a double. That asymmetry is the practical hazard: a document that survives a Python service untouched can lose digits in a Node one, so the safe convention across a mixed system is still to send large identifiers as strings, agreed at the API boundary rather than assumed by each side independently, since neither side raises when the assumption is wrong.

Why does Python allow NaN in JSON?

Because its json module accepts NaN, Infinity and -Infinity by default in both directions, which the JSON specification does not permit. We confirmed that json.dumps({"a": float("nan")}) emits {"a": NaN} without complaint and that json.loads('{"a": NaN}') parses it back. Any strict parser — including JavaScript's JSON.parse — rejects that document, so the output is not interchangeable JSON despite coming from a JSON library. Pass allow_nan=False to make dumps raise a ValueError instead, which is what you want at any boundary where the consumer is not also Python. It converts a silent incompatibility into an exception at the point of serialisation, which is where you can still act on it rather than at a consumer you do not control.

What happens to non-string dictionary keys?

They are coerced to strings, and one case loses data outright. Integers, floats, booleans and None become "1", "1.5", "true" and "null", while tuples raise a TypeError. The trap is that True == 1 in Python, so a dictionary containing both keys collapses: we measured {1: "a", True: "b", None: "c"} serialising to {"1": "b", "null": "c"} — the entry keyed 1 vanished silently because True overwrote it during coercion. Nothing raises. If your keys are not already strings, convert them yourself so you can see what happens. Doing it explicitly also documents which representation you chose, which the implicit coercion never does for whoever reads the code next, or debugs it later.

How do I detect duplicate keys when parsing?

Pass object_pairs_hook, which receives the key-value pairs in document order before they become a dictionary. By default json.loads keeps the last occurrence and discards the rest, so {"a":1,"a":2} parses to a value of 2 with no warning — the same behaviour JavaScript has. Supplying object_pairs_hook=lambda pairs: [k for k, _ in pairs] returns ['a', 'a'], which makes the duplication visible. In practice you would collect the keys, compare against a set, and raise on a repeat. This is the only way to see the problem, since the parsed dictionary cannot represent it — by the time you hold a dict, the duplicate has already been discarded and there is nothing left to inspect.

References

Check a document with the JSON formatter, which flags precision loss and duplicate keys. More tools at withuse.io/tools.