Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Python  Module 8 – Files, paths and data formats

JSON: load, dump and custom types

Convert between JSON text and Python objects with json.loads, dumps, load and dump, format output for diffs, store datetime and Decimal, and validate data.

  • Beginner
  • 20 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Convert between JSON text and Python objects, and predict what a round trip changes
  • Read and write JSON files, and report a syntax error with its line
  • Format JSON with indentation and sorted keys for readable diffs
  • Serialise custom types with default= and restore them with object_hook
  • Validate the shape, numbers and ranges of JSON you load

Before you start

What you’ll learn · Before you start

What you will learn

  • Convert between JSON text and Python objects, and predict what a round trip changes
  • Read and write JSON files, and report a syntax error with its line
  • Format JSON with indentation and sorted keys for readable diffs
  • Serialise custom types with default= and restore them with object_hook
  • Validate the shape, numbers and ranges of JSON you load

Before you start

On this page

json.loads(json.dumps(order)) gives back a dict that is not equal to the order it started from: a tuple comes back as a list and the key 3 as the string "3". JSON has fewer types than Python, so a round trip: Turning data into another form and back, such as a dict into JSON text and back again. A round trip that loses nothing gives an equal value; through JSON, tuples come back as lists and keys as strings. can change your data, and an identifier above 2⁵³ can come back as another number in any program that reads JSON numbers as floats.

JSON is a text format for data: objects in braces, arrays in brackets, strings in double quotes, numbers, true, false and null. Python’s json module converts between that text and ordinary Python objects. json.loads(text) turns JSON text into dictionaries, lists, strings, numbers, booleans and None, and json.dumps(obj) turns such objects back into text. Their partners json.load(f) and json.dump(obj, f) do the same with an open file. The s at the end of loads and dumps stands for string.

loads() and dumps() turn JSON text into objects and back

Each JSON type has one Python type it turns into, and each Python type that json can write has one JSON form:

JSON Python, after loads() Python types that dumps() writes this way
object {...} dict dict
array [...] list list, tuple
string "..." str str
number, whole int int
number, with a fraction or exponent float float
true, false True, False True, False
null None None

The table already shows where a round trip loses information. To serialise: To write data in a format that can be stored or sent, such as a dict as JSON text with json.dumps(); reading it back is deserialising. Only the types the format knows come back without help. data is to write it in a format such as JSON, and only what the format can say survives. Step through an order going to JSON and back:

Interactive · stage by stage An order to JSON text and back Step through round_trip.py. Watch the tuple and the key 3 on their way through the text.
An order to JSON text and back, step 6 of 6. Showing The order, a dict, json.dumps(order) (the key 3 becomes "3"), JSON text, a str ("items": […], "3": …), json.loads(text), The dict you get back (a list, and the key '3').The order, a dicta tuple, and the int key 3json.dumps(order)the key 3 becomes "3"JSON text, a str"items": […], "3": …json.loads(text)text to Python objectsThe dict you get backa list, and the key '3'a dictback == order: False

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The order. The order holds an int, a tuple of two items, True, None, a float, and one key that is the int 3 rather than a string.
  2. JSON forms. dumps() writes each value in its JSON form. True becomes true, None becomes null, and the tuple becomes an array, the only kind of list JSON has.
  3. Keys are strings. The keys of a JSON object are always strings, so the key 3 is written as "3".
  4. Just text. The result is a str, text that can go into a file, a request or a message, and nothing in it says which Python types it came from.
  5. Back to objects. loads() turns every array into a list and keeps every key as the string it finds.
  6. Not the same dict. back == order is False. The tuple came back as a list and the key 3 as "3", so code that reads JSON should expect lists and string keys.

An order to JSON and back Python · round_trip.py
import json

order = {"id": 17, "items": ("pen", "notebook"), "paid": True, "coupon": None, "total": 49.5, 3: "third shelf"}

text = json.dumps(order)  # Python object -> JSON text (a str)
print(text)
print(type(text).__name__)

back = json.loads(text)  # JSON text -> Python objects
print(back)
print("same as before?", back == order)
print("items is now a", type(back["items"]).__name__, "| keys:", list(back))

Output

{"id": 17, "items": ["pen", "notebook"], "paid": true, "coupon": null, "total": 49.5, "3": "third shelf"}
str
{'id': 17, 'items': ['pen', 'notebook'], 'paid': True, 'coupon': None, 'total': 49.5, '3': 'third shelf'}
same as before? False
items is now a list | keys: ['id', 'items', 'paid', 'coupon', 'total', '3']

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 round_trip.py

dumps() returned a str, which you can send over a network or write to a file. The order that came back is not equal to the one that went out: the tuple became a list, because JSON has only arrays, and the key 3 became the string "3", because the keys of a JSON object are always strings. Code that reads JSON should expect lists and string keys, whatever the code that wrote it used. Press Edit, make items a list and write the key 3 as the string "3", then run it again: this time the round trip gives back an equal dict.

Checkpoint

Check: JSON types and Python types

  1. One question What does this print?

    Read the code, then choose one answer.

    import json
    
    print(json.loads(json.dumps({1: (2, 3), "ok": None})))
    Show the answer: One question

    Answer: {'1': [2, 3], 'ok': None}

    JSON object keys are always strings, so 1 comes back as "1", and JSON has only arrays, so the tuple comes back as a list. null becomes None again.

load() and dump() read and write JSON files

json.load() reads JSON from a file object and json.dump() writes to one. A settings file is the classic use. Step through the life of one, from the first run to a broken hand edit:

Interactive · message by message A settings file from the first run to a broken edit Step through settings_file.py. Each step is one load or save, and the sub-labels say what the app and the file hold.
A settings file from the first run to a broken edit, step 6 of 6. Messages so far: The app to settings.json: open("settings.json"); The app to settings.json: json.dump(…, indent=2); settings.json to The app: json.load(f); settings.json to The app: JSONDecodeError.The apptheme dark, 1 recent filesettings.jsontrailing comma, line 3open("settings.json")json.dump(…, indent=2)json.load(f)JSONDecodeErrorline 3, column 18

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. First run. load_settings() tries to open settings.json. There is no file yet, so the FileNotFoundError is caught and the app starts from its defaults.
  2. A change. The user picks the dark theme, and marks.csv joins the list of recent files.
  3. Save. save_settings() writes the dict with json.dump(…, indent=2), one setting per line, so that a person can read and edit the file.
  4. Next run. load_settings() reads the file with json.load() and merges it over the defaults with |, so a setting added in a later version still gets its default.
  5. A hand edit. Someone edits the file by hand and leaves a comma after the last setting, which JSON does not allow.
  6. The error says where. json.load() raises JSONDecodeError. Its msg, lineno and colno give "Illegal trailing comma before end of object at line 3, column 18", which is what the user needs to see.

Here is the program the figure follows:

Save and load a settings file Python · settings_file.py
import json


def defaults():
    """A new dict on every call, so that changing the settings never changes the defaults."""
    return {"theme": "light", "font_size": 14, "recent": []}


def load_settings(path):
    """The saved settings, with defaults for anything missing; the defaults alone if there is no file yet."""
    try:
        with open(path, encoding="utf-8") as f:
            saved = json.load(f)  # load: from a file object
    except FileNotFoundError:
        return defaults()
    return defaults() | saved


def save_settings(path, settings):
    with open(path, "w", encoding="utf-8") as f:
        json.dump(settings, f, indent=2)  # dump: to a file object


settings = load_settings("settings.json")
print("first run:", settings)
settings["theme"] = "dark"
settings["recent"].append("marks.csv")
save_settings("settings.json", settings)

with open("settings.json", encoding="utf-8") as f:
    print(f.read())
print("next run:", load_settings("settings.json"))

# A file someone edited by hand, with a trailing comma, which JSON does not allow.
with open("settings.json", "w", encoding="utf-8") as f:
    f.write('{\n  "theme": "dark",\n  "font_size": 16,\n}\n')
try:
    load_settings("settings.json")
except json.JSONDecodeError as err:
    print(f"{err.msg} at line {err.lineno}, column {err.colno}")

Output

first run: {'theme': 'light', 'font_size': 14, 'recent': []}
{
  "theme": "dark",
  "font_size": 14,
  "recent": [
    "marks.csv"
  ]
}
next run: {'theme': 'dark', 'font_size': 14, 'recent': ['marks.csv']}
Illegal trailing comma before end of object at line 3, column 18

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 settings_file.py

The program starts with defaults when no file exists, merges the saved settings over the defaults with | so that a setting added in a later version still gets a value, and writes the file with indent=2 so that a person can read and edit it. Open JSON files with encoding="utf-8": RFC 8259, the JSON standard, requires UTF-8 for JSON that systems exchange.

Hand-edited files break in predictable ways, and the last lines show one. JSON allows no trailing comma, no comments, no single quotes and no unquoted keys. json.JSONDecodeError, a subclass of ValueError, carries the problem in msg and its place in lineno and colno, which is what an error message for the user should report.

Mixing up load() and loads()

json.load(text) fails because load() expects a file object, and json.loads(f) fails because loads() expects a string. The functions with an s take and return strings; the others read from and write to files.

Checkpoint

Check: JSON files and their errors

  1. Question 1 of 2 You have the body of an HTTP response in a variable body (a str). Which call turns it into Python objects?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: json.loads(body)

    loads() parses a string; load() expects a file object. dumps() goes the other way, and eval() would run the text as Python code, which is never acceptable for data from outside.

  2. Question 2 of 2 json.load(f) raises JSONDecodeError on a settings file that someone edited by hand. Which attributes of the error tell the user what to fix?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: msg, lineno and colno

    JSONDecodeError is a subclass of ValueError that records where parsing failed: msg is the reason, and lineno and colno the line and column, counted from 1. Report them, and the user can open the file and go straight there.

Indenting and sorting keys make files people can read

By default dumps() writes everything on one line and escapes every character outside ASCII. Three options change that, and a fourth makes the output as small as it can be:

ensure_ascii, indent, sort_keys and separators Python · readable.py
import json

students = [{"name": "अनन्या", "city": "Nagpur", "marks": [91, 88]}, {"name": "Zoë", "city": "Pune", "marks": [76, 95]}]

print(json.dumps(students[0]))  # ensure_ascii=True (the default) escapes every non-ASCII character
print(json.dumps(students[0], ensure_ascii=False))  # keeps the characters themselves

# indent and sort_keys give stable, readable files that diff well in version control.
print(json.dumps(students[1], indent=2, sort_keys=True, ensure_ascii=False))

compact = json.dumps(students, separators=(",", ":"), ensure_ascii=False)
spaced = json.dumps(students, ensure_ascii=False)
print(len(compact.encode("utf-8")), "bytes compact,", len(spaced.encode("utf-8")), "bytes with the default spaces")

Output

{"name": "\u0905\u0928\u0928\u094d\u092f\u093e", "city": "Nagpur", "marks": [91, 88]}
{"name": "अनन्या", "city": "Nagpur", "marks": [91, 88]}
{
  "city": "Pune",
  "marks": [
    76,
    95
  ],
  "name": "Zoë"
}
109 bytes compact, 122 bytes with the default spaces

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 readable.py

The table sums up what each option did to the two students:

Option What it changes In the example
ensure_ascii=False keeps characters such as अनन्या and Zoë as they are the name instead of six \u escapes
indent=2 puts each item on its own line, indented by nesting depth Zoë’s record over eight lines
sort_keys=True writes the keys of every object in sorted order city, marks, name
separators=(",", ":") drops the spaces after commas and colons 109 bytes instead of 122

Escaped or not, the two forms are valid JSON and load to the same string, but only one can be read; write such text to a file opened with encoding="utf-8". Sorted keys make the same data always give the same text, so version control shows only the values that really changed, and tests can compare output with expected text. The compact separators made the output about 11 % smaller here, which suits data sent over a network more than files that people read.

Leaving ensure_ascii on for files that people read

json.dump(data, f) writes every character outside ASCII as an escape, so अनन्या becomes six of them. Pass ensure_ascii=False, and open the file with encoding="utf-8".

Pretty-printing from the command line

python -m json file.json prints a file formatted with an indent of four spaces, --sort-keys and --indent change that, and invalid JSON gives an error with its line and column:

python -m json on a good file and a broken one Python · cli.py
import subprocess
import sys

with open("order.json", "w", encoding="utf-8") as f:
    f.write('{"total": 49.5, "id": 17, "items": ["pen", "notebook"]}')

# python -m json (Python 3.14) pretty-prints a file; --sort-keys and --indent are two of its options.
result = subprocess.run([sys.executable, "-m", "json", "--sort-keys", "--indent", "2", "order.json"],
                        capture_output=True, text=True)
print(result.stdout, end="")

with open("broken.json", "w", encoding="utf-8") as f:
    f.write('{"id": 17, "items": ["pen" "notebook"]}')
result = subprocess.run([sys.executable, "-m", "json", "broken.json"], capture_output=True, text=True)
print("exit status", result.returncode, "|", result.stderr, end="")

Output

{
  "id": 17,
  "items": [
    "pen",
    "notebook"
  ],
  "total": 49.5
}
exit status 1 | Expecting ',' delimiter: line 1 column 28 (char 27)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cli.py

In a terminal, Python 3.14 also colours the output; the colours disappear when the output goes to a file or another program, as it does here. On older versions, run python -m json.tool with the same options.

JSON Formatter Paste the output of any example to indent it, sort its keys or find the line and column of a syntax error.

Checkpoint

Check: Output that people can read

  1. One question What does this print?

    Read the code, then choose one answer.

    import json
    
    print(json.dumps({"name": "Zoë"}))
    Show the answer: One question

    Answer: {"name": "Zo\u00eb"}

    Both forms are valid JSON and load to the same string, but only one can be read. Write files for people with ensure_ascii=False, to a file opened with encoding="utf-8".

A default function writes the types JSON lacks

dumps() refuses any object it has no rule for, and Python 3.14 adds a note that says where in the data the value was. You supply the rule with default=, a function that dumps() calls for every value it cannot write. Step through the four values of an order, first without the rule and then with it:

Interactive · stage by stage Four values through dumps(), default= and object_hook Step through the order of custom_types.py. Values JSON knows go straight to the text; the others go through encode() first.
Four values through dumps(), default= and object_hook, step 8 of 8. Showing dumps() takes a value (created_at: a datetime), A type JSON has?, Your encode(value) (returns its ISO 8601 text), JSON text, The way back: loads() (created_at: a datetime again), The order you get back ('1999.90' and a list).yes: written as it isdumps() takes a valuecreated_at: a datetimeA type JSON has?dict, list, str, int, float …Your encode(value)returns its ISO 8601 textJSON texta strThe way back: loads()created_at: a datetime againThe order you get back'1999.90' and a lista dict

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. id. id is 501, an int, a type JSON has, so dumps() writes it as it is.
  2. Stopped at amount. amount is a Decimal. Without default=, dumps() stops here with TypeError, and Python 3.14 adds a note that names the key, amount.
  3. encode() for the Decimal. With default=encode, dumps() calls encode() for the Decimal instead, and it returns the string "1999.90", so no digit is rounded away.
  4. A set. tags is a set, which JSON lacks too. encode() returns a sorted list, so the text is the same on every run.
  5. A datetime. created_at is a datetime. encode() returns its isoformat() text, time zone included.
  6. The text. Every value now has a JSON form, and dumps() returns the text with amount and created_at as strings and tags as a list.
  7. The hook. loads() calls object_hook with every object it decodes. decode() turns the strings under keys that end in _at back into datetime objects.
  8. What came back. amount and tags come back as a string and a list. The text kept no record of their types, so only a rule such as _at, or a schema both sides share, can restore them.

An encoder for datetime, Decimal and set, and a hook for the way back Python · custom_types.py
import json
from datetime import datetime, timezone
from decimal import Decimal

order = {"id": 501, "amount": Decimal("1999.90"), "tags": {"gift", "express"},
         "created_at": datetime(2025, 3, 14, 9, 30, tzinfo=timezone.utc)}

try:
    json.dumps(order)
except TypeError as err:
    print("TypeError:", err)
    print("notes:", err.__notes__)  # Python 3.14 says where the value was


def encode(value):
    """Called by dumps() for every value it cannot write itself."""
    if isinstance(value, datetime):
        return value.isoformat()
    if isinstance(value, Decimal):
        return str(value)  # a string keeps every digit
    if isinstance(value, set):
        return sorted(value)  # a list, in a fixed order
    raise TypeError(f"cannot store {type(value).__name__} as JSON")


text = json.dumps(order, default=encode)
print(text)


def decode(obj):
    """Called by loads() for every JSON object: turn the *_at fields back into datetimes."""
    for key, value in obj.items():
        if key.endswith("_at"):
            obj[key] = datetime.fromisoformat(value)
    return obj


back = json.loads(text, object_hook=decode)
print(repr(back["created_at"]))
print(repr(back["amount"]), "and", back["tags"], "(JSON kept no record that they were a Decimal and a set)")

Output

TypeError: Object of type Decimal is not JSON serializable
notes: ["when serializing dict item 'amount'"]
{"id": 501, "amount": "1999.90", "tags": ["express", "gift"], "created_at": "2025-03-14T09:30:00+00:00"}
datetime.datetime(2025, 3, 14, 9, 30, tzinfo=datetime.timezone.utc)
'1999.90' and ['express', 'gift'] (JSON kept no record that they were a Decimal and a set)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 custom_types.py

encode() returns something JSON can store for each type it knows and raises TypeError for the rest, as dumps() itself would. Each choice keeps information: isoformat() writes a datetime as ISO 8601: The international standard for writing dates and times year first, then month, day and time, with an offset such as +05:30 or Z for UTC. isoformat() writes it and fromisoformat() reads it. More in “Dates, times and time zones (IST included)” text with its time zone, a Decimal becomes a string so that no digit is rounded away, and a set becomes a sorted list so that the output is the same on every run:

Value of the order What encode() returns After loads() with the hook
amount, a Decimal the string "1999.90" still a string
tags, a set the sorted list ["express", "gift"] a list
created_at, a datetime its ISO 8601 text a datetime again

For the way back, object_hook is called with every JSON object that loads() decodes, and its return value takes the object’s place; here it turns the strings under keys ending in _at back into datetime objects. The last line shows the limit of the approach. The JSON text says nothing about types, so the reader can restore a value only when it knows what to expect, through a naming rule such as _at, a field that names the type, or a schema that both sides share. When many types are involved, subclass json.JSONEncoder and override its default() method instead of passing a function each time.

Version note

New in Python 3.14: errors raised while serialising carry notes that name the dictionary key or the list position of the failing value, and python -m json pretty-prints JSON from the command line (python -m json.tool still works).

Checkpoint

Check: Types that JSON lacks

  1. One question Which of these values can json.dumps() write without a default= function?

    Choose every answer that is right.

    Show the answer: One question

    Answer:

    • {"when": None}
    • (1, 2, 3)
    • [1, 2, 3]

    Lists and tuples become arrays and None becomes null. Sets and Decimals have no JSON form, so dumps() raises TypeError unless default= turns them into something it can write, such as a sorted list or a string.

JSON numbers have no precision of their own

A JSON number is a run of digits with no size or precision attached, so what it means depends on the program that reads it:

Floats, big integers, NaN and Decimal Python · json_numbers.py
import json
from decimal import Decimal

print(json.dumps([0.1 + 0.2, 10**20, float("nan"), float("inf")]))  # NaN and Infinity are not JSON

try:
    json.dumps({"ratio": float("nan")}, allow_nan=False)  # strict JSON for other systems
except ValueError as err:
    print("ValueError:", err)

print(json.loads('{"price": 19.99}'))  # a float
print(json.loads('{"price": 19.99}', parse_float=Decimal))  # the exact decimal value
print(json.loads('{"x": 1, "x": 2}'))  # a repeated key: the last one wins

big = 2**53 + 1  # an identifier just above 2**53
print(big, int(float(big)))  # what a reader that stores numbers as 64-bit floats keeps

Output

[0.30000000000000004, 100000000000000000000, NaN, Infinity]
ValueError: Out of range float values are not JSON compliant: nan
{'price': 19.99}
{'price': Decimal('19.99')}
{'x': 2}
9007199254740993 9007199254740992

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 json_numbers.py

  • Python writes a float with the shortest text that reads back as the same float, so 0.1 + 0.2 appears as 0.30000000000000004: the rounding happened in the arithmetic, not in JSON.
  • Python reads and writes integers of any size, but many programs read every JSON number as a 64-bit IEEE 754: The standard for floating-point arithmetic that almost every computer follows. A Python float is its 64-bit double, a binary fraction with 53 significant bits and an exponent. More in “Floating-point surprises and how to handle them” float, which stores whole numbers exactly only up to 2⁵³, about nine million billion. The last line shows what such a program keeps of 2**53 + 1: one less. Send large identifiers as strings.
  • NaN and Infinity are not part of JSON, yet dumps() writes them by default, and other programs then fail to parse the file. Pass allow_nan=False to get a ValueError instead, or replace them with null yourself.
  • parse_float=Decimal reads numbers with a fraction as exact Decimal values, which matters for money.
  • When an object repeats a key, the last value wins without a warning. object_pairs_hook receives every pair and can refuse duplicates.

Storing money as floats

json.loads('{"price": 19.99}') gives a binary float, and sums of such floats drift away from the exact amounts. Send amounts as strings or as whole numbers of the smallest unit, such as cents, and read them with Decimal.

Checkpoint

Check: Numbers in JSON

  1. Question 1 of 2 What does json.dumps(float("nan")) return?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: "NaN", which is not valid JSON, so other programs may fail to read it

    Python writes NaN, Infinity and -Infinity by default although the JSON standard has no such values. Pass allow_nan=False to get a ValueError instead, and decide yourself what to send.

  2. Question 2 of 2 Your API sends the order id 9007199254740993 as a JSON number. A client that reads every number as a 64-bit float stores which id?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 9007199254740992, one less, because a 64-bit float cannot hold that whole number exactly

    RFC 8259 notes that integers are interoperable only within the range a 64-bit float holds exactly, up to 2⁵³. Send large identifiers as strings, "9007199254740993", and every reader keeps every digit.

Parsing proves the text is JSON, not that the data is right

JSON is data, never code: parse it with json.loads(), never with eval(), which would run whatever the text contains. Parsing only tells you that the text is valid JSON. The result can still be a list where you expected an object, a string where you expected a number, or a quantity of 5,000. Check the shape before you use it:

Checking an order before using it Python · validate.py
import json


def check_order(data):
    """Raise ValueError unless data has the shape an order must have."""
    if not isinstance(data, dict):
        raise ValueError("an order must be a JSON object")
    if not isinstance(data.get("id"), int) or isinstance(data.get("id"), bool):
        raise ValueError("id must be a whole number")
    items = data.get("items")
    if not isinstance(items, list) or not items:
        raise ValueError("items must be a non-empty list")
    for i, item in enumerate(items):
        if not isinstance(item, dict) or not isinstance(item.get("sku"), str):
            raise ValueError(f"items[{i}] needs a text sku")
        qty = item.get("qty")
        if not isinstance(qty, int) or isinstance(qty, bool) or not 1 <= qty <= 100:
            raise ValueError(f"items[{i}].qty must be a whole number from 1 to 100")
    return data


requests = [
    '{"id": 7, "items": [{"sku": "PEN-01", "qty": 2}]}',
    '[1, 2, 3]',
    '{"id": "7", "items": [{"sku": "PEN-01", "qty": 2}]}',
    '{"id": 8, "items": [{"sku": "PEN-01", "qty": true}]}',
    '{"id": 9, "items": [{"sku": "PEN-01", "qty": 5000}]}',
    '{"id": 10, "items": [{"sku": "PEN-01", "qty": 1}',
]
for text in requests:
    try:
        order = check_order(json.loads(text))
        print("accepted order", order["id"])
    except json.JSONDecodeError as err:
        print("not JSON:", err.msg)
    except ValueError as err:
        print("refused:", err)

Output

accepted order 7
refused: an order must be a JSON object
refused: id must be a whole number
refused: items[0].qty must be a whole number from 1 to 100
refused: items[0].qty must be a whole number from 1 to 100
not JSON: Expecting ',' delimiter

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 validate.py

Each check names the field it refused, so the client learns what to fix. One check is less obvious: True and False are instances of int in Python, so a test that only asks isinstance(qty, int) accepts "qty": true as a quantity of 1. For larger formats, write the rules once as a A JSON document that describes the shape other JSON must have, its types, required fields and ranges, so that a library can check data against it. and check data against it with a schema library, and for input from the internet, limit its size first: the json documentation warns that a malicious document can make the decoder use a lot of CPU time and memory.

Key idea

json.loads() proves only that the text is JSON: check the type, the fields and the ranges of what it returns before any of it reaches your logic.

Checkpoint

Check: Checking what you load

  1. One question A check accepts a quantity when isinstance(qty, int) is true. What does it do with {"qty": true}?

    Choose one answer.

    Show the answer: One question

    Answer: It accepts it as a quantity of 1, because True is an instance of int

    bool is a subclass of int, so isinstance(True, int) is True. A check for a whole number must also rule out bool, as check_order() does, or a client can send true and order one item.

Practise: store dates, decimals and sets

The exercise asks for two functions. to_json() writes datetimes as ISO 8601 text, Decimals as strings and sets as sorted lists, at any depth, with sorted keys and non-ASCII text kept as it is; from_json() turns every string under a key that ends in _at back into a datetime. Its sample tests run in your browser:

Exercise · Medium · Python

Store dates, decimals and sets in JSON

Write two functions in jsontypes.py.

  • to_json(obj) returns JSON text for obj, which may hold, at any depth inside dictionaries and lists, three types that json.dumps() cannot write by itself: datetime.datetime values, written as ISO 8601 text with isoformat(); decimal.Decimal values, written as strings so that no digit is lost; and sets, written as sorted lists. Keys are sorted and non-ASCII characters are written as they are, so pass sort_keys=True and ensure_ascii=False. Any other value that JSON cannot store, such as a datetime.date or a complex number, raises TypeError.
  • from_json(text) parses JSON text and turns every string whose key ends with _at back into a datetime, in objects at any depth. Everything else stays as json.loads() gives it: a decimal comes back as a string, a set as a list, and a null under an _at key as None.

Starter code · jsontypes.py

import json


def to_json(obj):
    """JSON text for obj; datetimes, Decimals and sets included."""
    # Replace this line with your code.
    return json.dumps(obj)


def from_json(text):
    """The data of text, with the *_at strings turned back into datetimes."""
    # Replace this line with your code.
    return json.loads(text)
The sample tests · test_jsontypes.py
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal

from jsontypes import from_json, to_json

IST = timezone(timedelta(hours=5, minutes=30))
ORDER = {
    "id": 501,
    "amount": Decimal("1999.90"),
    "tags": {"gift", "express"},
    "created_at": datetime(2025, 3, 14, 9, 30, tzinfo=IST),
    "lines": [{"sku": "PEN-01", "price": Decimal("49.50"), "shipped_at": datetime(2025, 3, 15, 18, 0)}],
}


def test_writes_the_three_types():
    """writes datetimes as ISO 8601, Decimals as strings and sets as sorted lists, keys sorted"""
    assert to_json(ORDER) == (
        '{"amount": "1999.90", "created_at": "2025-03-14T09:30:00+05:30", "id": 501, '
        '"lines": [{"price": "49.50", "shipped_at": "2025-03-15T18:00:00", "sku": "PEN-01"}], '
        '"tags": ["express", "gift"]}'
    )


def test_keeps_non_ascii_text():
    """writes non-ASCII characters as they are"""
    assert to_json({"name": "अनन्या", "city": "Zürich"}) == '{"city": "Zürich", "name": "अनन्या"}'


def test_other_types_still_fail():
    """raises TypeError for a date, a complex number or any other object"""
    for value in [date(2025, 3, 14), 1 + 2j, object()]:
        try:
            to_json({"x": value})
        except TypeError:
            continue
        assert False, f"to_json() should raise TypeError for {value!r}"


def test_reads_datetimes_back():
    """turns the *_at strings back into datetimes, at any depth, and leaves the rest alone"""
    back = from_json(to_json(ORDER))
    assert back["created_at"] == ORDER["created_at"]
    assert back["lines"][0]["shipped_at"] == datetime(2025, 3, 15, 18, 0)
    assert back["amount"] == "1999.90"
    assert back["tags"] == ["express", "gift"]
    assert back["lines"][0]["sku"] == "PEN-01"


def test_null_and_other_keys():
    """keeps None under an _at key, and strings under other keys"""
    back = from_json('{"deleted_at": null, "format": "2025-03-14T09:30:00", "items": [{"paid_at": "2025-01-02T03:04:05+00:00"}]}')
    assert back["deleted_at"] is None
    assert back["format"] == "2025-03-14T09:30:00"
    assert back["items"][0]["paid_at"] == datetime(2025, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
A hint

Give json.dumps() a function as default=: it is called with every value that dumps() cannot write, and it returns something that it can (a string or a list), or raises TypeError. For the way back, json.loads(text, object_hook=fix) calls fix(d) with every decoded object, innermost first, and uses what fix returns; change the values of the keys that end with "_at" with datetime.fromisoformat() when they are strings.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

The practice below covers types, files, readable output, custom types, numbers and validation. Your weakest idea comes first, then one question follows another, and each choice is explained the moment you pick it:

Practice that adapts to you

Practise this lesson

18 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 18 A dict with the int key 3 goes through json.dumps() and back through json.loads(). What is the key now?

    Choose one answer.

    Show the answer: Question 1 of 18

    Answer: The string "3"

    The json documentation says it directly: loads(dumps(x)) != x if x has non-string keys. Code that reads JSON should expect string keys, whatever the code that wrote it used.

  2. Question 2 of 18 What does this print?

    Read the code, then choose one answer.

    import json
    
    order = {"items": ("pen", "notebook")}
    back = json.loads(json.dumps(order))
    print(back["items"] == ("pen", "notebook"), back["items"] == ["pen", "notebook"])
    Show the answer: Question 2 of 18

    Answer: False True

    JSON has only arrays, and loads() turns every array into a list. Compare with a list, or convert with tuple() after loading when the code needs a tuple.

  3. Question 3 of 18 Two services exchange orders as JSON. One stores order lines as tuples and uses the int product code as a dict key. Which plan keeps both sides working?

    Choose one answer.

    Show the answer: Question 3 of 18

    Answer: Agree on what the JSON holds, lists and string keys, and convert to tuples and ints on the reading side if the code needs them

    JSON describes data, not Python objects, so the format between two programs is a contract: decide what the text holds, and let each side convert to and from its own types.

  4. Question 4 of 18 What does json.dump(settings, f, indent=2) return?

    Choose one answer.

    Show the answer: Question 4 of 18

    Answer: None; it writes the JSON text into the file f

    The functions with an s work on strings, the ones without on file objects. dump(obj, f) writes the text into f, and load(f) reads and parses a whole file.

  5. Question 5 of 18 What does this print?

    Read the code, then choose one answer.

    DEFAULTS = {"theme": "light", "font_size": 14, "recent": []}
    saved = {"theme": "dark"}
    print(DEFAULTS | saved)
    Show the answer: Question 5 of 18

    Answer: {'theme': 'dark', 'font_size': 14, 'recent': []}

    Merging the saved settings over the defaults gives every setting a value, including settings added in a later version of the program, which an older file does not hold.

  6. Question 6 of 18 A network library hands you the response body as bytes. What does json.loads(body) do with them?

    Choose one answer.

    Show the answer: Question 6 of 18

    Answer: It decodes them itself, as UTF-8, UTF-16 or UTF-32, and parses the text

    json.loads() accepts str, bytes and bytearray. For a file, open it in text mode with encoding="utf-8" and use load(): RFC 8259 requires UTF-8 for JSON that systems exchange.

  7. Question 7 of 18 Why does a settings file written with sort_keys=True work better in version control?

    Choose one answer.

    Show the answer: Question 7 of 18

    Answer: The same data always gives the same text, so a diff shows only the values that really changed

    With sort_keys=True and indent=2, every key of every object comes out in the same order on its own line, so tests can compare output with expected text and diffs stay small.

  8. Question 8 of 18 In readable.py, the students list took 122 bytes with the default spaces and 109 bytes with separators=(",", ":"). How many bytes did the compact form save?

    Type a number.

    Show the answer: Question 8 of 18

    Answer: 13 bytes

    122 − 109 = 13 bytes, about 11 % of the default output. The default separators put a space after every comma and every colon; the compact ones drop both, which suits data sent over a network more than files that people read.

  9. Question 9 of 18 A build script must stop when any JSON file of a project is broken. Which fact about python -m json makes it usable for that?

    Choose one answer.

    Show the answer: Question 9 of 18

    Answer: It exits with status 1 and prints the line and column when a file is not valid JSON

    Command-line tools report failure through their exit status. python -m json returns 1 for invalid JSON, so a script can run it on every file and stop at the first failure.

  10. Question 10 of 18 When does json.dumps() call the function given as default=?

    Choose one answer.

    Show the answer: Question 10 of 18

    Answer: For every value it cannot write by itself, such as a Decimal, a set or a datetime

    default must return something JSON can store, or raise TypeError as dumps() itself would. It is called for each value that has no JSON form, at any depth of the data.

  11. Question 11 of 18 What does custom_types.py print?

    What does this program print? Choose one answer.

    import json
    from datetime import datetime, timezone
    from decimal import Decimal
    
    order = {"id": 501, "amount": Decimal("1999.90"), "tags": {"gift", "express"},
             "created_at": datetime(2025, 3, 14, 9, 30, tzinfo=timezone.utc)}
    
    try:
        json.dumps(order)
    except TypeError as err:
        print("TypeError:", err)
        print("notes:", err.__notes__)  # Python 3.14 says where the value was
    
    
    def encode(value):
        """Called by dumps() for every value it cannot write itself."""
        if isinstance(value, datetime):
            return value.isoformat()
        if isinstance(value, Decimal):
            return str(value)  # a string keeps every digit
        if isinstance(value, set):
            return sorted(value)  # a list, in a fixed order
        raise TypeError(f"cannot store {type(value).__name__} as JSON")
    
    
    text = json.dumps(order, default=encode)
    print(text)
    
    
    def decode(obj):
        """Called by loads() for every JSON object: turn the *_at fields back into datetimes."""
        for key, value in obj.items():
            if key.endswith("_at"):
                obj[key] = datetime.fromisoformat(value)
        return obj
    
    
    back = json.loads(text, object_hook=decode)
    print(repr(back["created_at"]))
    print(repr(back["amount"]), "and", back["tags"], "(JSON kept no record that they were a Decimal and a set)")
    Show the answer: Question 11 of 18

    Answer: it prints

    TypeError: Object of type Decimal is not JSON serializable
    notes: ["when serializing dict item 'amount'"]
    {"id": 501, "amount": "1999.90", "tags": ["express", "gift"], "created_at": "2025-03-14T09:30:00+00:00"}
    datetime.datetime(2025, 3, 14, 9, 30, tzinfo=datetime.timezone.utc)
    '1999.90' and ['express', 'gift'] (JSON kept no record that they were a Decimal and a set)

    Without default=, dumps() stops at amount and Python 3.14 notes where. encode() writes the Decimal as a string, the set as a sorted list and the datetime as ISO 8601 text, and object_hook can bring back only what its naming rule recognises.

  12. Question 12 of 18 After a round trip, amount is the string "1999.90" again, not a Decimal. Which change lets the reading side restore it?

    Choose one answer.

    Show the answer: Question 12 of 18

    Answer: Agree on a rule both sides know, such as a field name, a field that names the type, or a shared schema, and convert in the hook

    A naming rule such as _at, a type field such as {"$decimal": "1999.90"}, or a schema both sides share tells the reader what each string means. With that knowledge, object_hook turns it back into the right type.

  13. Question 13 of 18 Which call reads {"price": 19.99} with the price as an exact Decimal?

    Choose one answer.

    Show the answer: Question 13 of 18

    Answer: json.loads(text, parse_float=Decimal)

    parse_float=Decimal hands the number's own text to Decimal, before any float exists, so 19.99 stays exactly 19.99. That matters for money.

  14. Question 14 of 18 What does json_numbers.py print?

    What does this program print? Choose one answer.

    import json
    from decimal import Decimal
    
    print(json.dumps([0.1 + 0.2, 10**20, float("nan"), float("inf")]))  # NaN and Infinity are not JSON
    
    try:
        json.dumps({"ratio": float("nan")}, allow_nan=False)  # strict JSON for other systems
    except ValueError as err:
        print("ValueError:", err)
    
    print(json.loads('{"price": 19.99}'))  # a float
    print(json.loads('{"price": 19.99}', parse_float=Decimal))  # the exact decimal value
    print(json.loads('{"x": 1, "x": 2}'))  # a repeated key: the last one wins
    
    big = 2**53 + 1  # an identifier just above 2**53
    print(big, int(float(big)))  # what a reader that stores numbers as 64-bit floats keeps
    Show the answer: Question 14 of 18

    Answer: it prints

    [0.30000000000000004, 100000000000000000000, NaN, Infinity]
    ValueError: Out of range float values are not JSON compliant: nan
    {'price': 19.99}
    {'price': Decimal('19.99')}
    {'x': 2}
    9007199254740993 9007199254740992

    dumps() writes the float exactly as Python holds it, any integer in full and NaN and Infinity as non-standard words. allow_nan=False refuses NaN with ValueError, parse_float=Decimal reads 19.99 exactly, a repeated key keeps its last value, and 2**53 + 1 does not survive a trip through a 64-bit float.

  15. Question 15 of 18 An endpoint must refuse a body such as {"qty": 1, "qty": 50} instead of quietly using 50. How?

    Choose one answer.

    Show the answer: Question 15 of 18

    Answer: Parse with an object_pairs_hook that raises ValueError when a key appears twice

    The JSON standard leaves repeated names undefined, and Python keeps the last value without a warning. Only the pairs hook sees all of them, so that is where a strict parser refuses the body.

  16. Question 16 of 18 Why must JSON from outside be parsed with json.loads() and never with eval()?

    Choose one answer.

    Show the answer: Question 16 of 18

    Answer: eval() runs whatever the text contains as Python code, while loads() only builds data

    JSON is data, never code. json.loads() parses it into plain objects; eval() would execute any expression an attacker sends.

  17. Question 17 of 18 What does validate.py print?

    What does this program print? Choose one answer.

    import json
    
    
    def check_order(data):
        """Raise ValueError unless data has the shape an order must have."""
        if not isinstance(data, dict):
            raise ValueError("an order must be a JSON object")
        if not isinstance(data.get("id"), int) or isinstance(data.get("id"), bool):
            raise ValueError("id must be a whole number")
        items = data.get("items")
        if not isinstance(items, list) or not items:
            raise ValueError("items must be a non-empty list")
        for i, item in enumerate(items):
            if not isinstance(item, dict) or not isinstance(item.get("sku"), str):
                raise ValueError(f"items[{i}] needs a text sku")
            qty = item.get("qty")
            if not isinstance(qty, int) or isinstance(qty, bool) or not 1 <= qty <= 100:
                raise ValueError(f"items[{i}].qty must be a whole number from 1 to 100")
        return data
    
    
    requests = [
        '{"id": 7, "items": [{"sku": "PEN-01", "qty": 2}]}',
        '[1, 2, 3]',
        '{"id": "7", "items": [{"sku": "PEN-01", "qty": 2}]}',
        '{"id": 8, "items": [{"sku": "PEN-01", "qty": true}]}',
        '{"id": 9, "items": [{"sku": "PEN-01", "qty": 5000}]}',
        '{"id": 10, "items": [{"sku": "PEN-01", "qty": 1}',
    ]
    for text in requests:
        try:
            order = check_order(json.loads(text))
            print("accepted order", order["id"])
        except json.JSONDecodeError as err:
            print("not JSON:", err.msg)
        except ValueError as err:
            print("refused:", err)
    Show the answer: Question 17 of 18

    Answer: it prints

    accepted order 7
    refused: an order must be a JSON object
    refused: id must be a whole number
    refused: items[0].qty must be a whole number from 1 to 100
    refused: items[0].qty must be a whole number from 1 to 100
    not JSON: Expecting ',' delimiter

    Parsing comes first: the cut-off body fails there, with JSONDecodeError. Every body that parses then meets check_order(), which refuses a list, an id in quotes, a quantity of true and one of 5000, each with its field.

  18. Question 18 of 18 An endpoint receives JSON bodies from the internet. What should it do before calling json.loads()?

    Choose one answer.

    Show the answer: Question 18 of 18

    Answer: Refuse bodies above a size limit, since the json documentation warns that a malicious document can use a lot of CPU and memory

    Limit the size first, then parse inside a try statement, then check the shape and the ranges of the result, as check_order() does. Each step only lets through what the next one can handle.

Interview questions

What is the difference between json.load() and json.loads(), and between json.dump() and json.dumps()?

The functions with an s work on strings: json.loads(text) parses JSON text that is already in memory, such as the body of an HTTP response, and json.dumps(obj) returns the JSON text as a str. The functions without the s work on file objects: json.load(f) reads the JSON from an open file and parses it, and json.dump(obj, f) writes the JSON text into an open file and returns nothing. They take the same formatting and conversion options, such as indent, sort_keys, default and object_hook. Open the file with encoding="utf-8", and for load() read it in text mode: json.loads() also accepts bytes, which it decodes as UTF-8, UTF-16 or UTF-32.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    An order service reads amounts such as 366.25 from JSON with json.loads(), computes 18 % tax on each and rounds it to two decimals. Over 100,000 orders its tax total differs from the payment provider's by 15.72. Explain why, and fix how amounts are read, calculated and written.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    An HTTP endpoint passes the request body straight to json.loads() and then into business logic. What can a buggy client or an attacker send that hurts the service, and which limits and checks do you add?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • loads() and dumps() convert between JSON text and Python objects; load() and dump() read from and write to files opened with encoding="utf-8".
  • JSON objects become dicts, arrays lists and null None; tuples come back as lists and every key as a string.
  • indent=2, sort_keys=True and ensure_ascii=False make files that people can read and that diff cleanly.
  • default= stores types JSON lacks, such as datetime (ISO 8601), Decimal (a string) and set (a sorted list); object_hook restores them, but only when the reader knows what to expect.
  • Python writes NaN and Infinity unless allow_nan=False, a reader that uses 64-bit floats rounds integers above 2⁵³, and parse_float=Decimal reads exact decimals.
  • Parse with json.loads(), never eval(), and check the shape and the ranges of what you loaded before using it.

Terms in this lesson

round trip
Turning data into another form and back, such as a dict into JSON text and back again. A round trip that loses nothing gives an equal value; through JSON, tuples come back as lists and keys as strings.
serialise
To write data in a format that can be stored or sent, such as a dict as JSON text with json.dumps(); reading it back is deserialising. Only the types the format knows come back without help.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.