JSON in Python

JSON is the standard way to move structured data between programs. Four functions cover it, and the type mapping has a few asymmetries worth knowing.

The four functions

FunctionDirectionWorks with
json.dumps(obj)Python to JSONA string
json.loads(text)JSON to PythonA string
json.dump(obj, file)Python to JSONA file
json.load(file)JSON to PythonA file

The s means "string". Without it, the function reads from or writes to a file object.

import json

record = {"name": "Meera", "age": 27, "tags": ["staff", "admin"], "active": True}

text = json.dumps(record)
print(text)
print(type(text))                # <class 'str'>

restored = json.loads(text)
print(restored == record)        # True

The type mapping

PythonJSONBack as
dictobjectdict
listarraylist
tuplearraylist
strstringstr
intnumberint
floatnumberfloat
True / Falsetrue / falsebool
NonenullNone
import json

original = {"point": (1, 2), "count": 3}
restored = json.loads(json.dumps(original))

print(restored)                       # {'point': [1, 2], 'count': 3}
print(type(restored["point"]))        # <class 'list'> - the tuple is gone
The round trip is not always exact. Tuples come back as lists, and dictionary keys that were integers come back as strings, because JSON object keys must be strings.
import json

original = {1: "one", 2: "two"}
restored = json.loads(json.dumps(original))
print(restored)                       # {'1': 'one', '2': 'two'} - keys are now text

# Restore them deliberately if the keys matter
print({int(k): v for k, v in restored.items()})

Formatting the output

import json

data = {"b": 2, "a": 1, "nested": {"y": [1, 2], "x": None}}

print(json.dumps(data))                                   # compact, one line
print(json.dumps(data, indent=2))                         # readable
print(json.dumps(data, indent=2, sort_keys=True))         # stable key order
print(json.dumps(data, separators=(",", ":")))            # smallest possible
print(json.dumps({"city": "पुणे"}))                        # escaped by default
print(json.dumps({"city": "पुणे"}, ensure_ascii=False))    # readable Unicode

Use indent=2 for anything a person will read, and separators=(",", ":") for anything sent over a network. Use sort_keys=True when the output is compared or committed to version control.

Files

import json
from pathlib import Path

settings = {"theme": "dark", "size": 14, "recent": ["a.txt", "b.txt"]}

with open("settings.json", "w", encoding="utf-8") as handle:
    json.dump(settings, handle, indent=2, ensure_ascii=False)

with open("settings.json", encoding="utf-8") as handle:
    loaded = json.load(handle)

print(loaded == settings)

# The pathlib one liner, for small files
Path("settings.json").write_text(json.dumps(settings, indent=2), encoding="utf-8")
print(json.loads(Path("settings.json").read_text(encoding="utf-8")))

Handling failure

import json


def load_settings(path, defaults=None):
    defaults = defaults or {}
    try:
        with open(path, encoding="utf-8") as handle:
            return {**defaults, **json.load(handle)}
    except FileNotFoundError:
        print(f"{path} not found, using defaults")
        return defaults
    except json.JSONDecodeError as error:
        print(f"{path} is not valid JSON at line {error.lineno}: {error.msg}")
        return defaults


print(load_settings("missing.json", {"theme": "light"}))
import json

try:
    json.loads('{"name": "Meera", }')       # a trailing comma is not valid JSON
except json.JSONDecodeError as error:
    print(error.msg, "at position", error.pos)

JSON is stricter than Python: no trailing commas, no comments, no single quotes, and true, false and null are lowercase. Always catch JSONDecodeError on data you did not write.

Types JSON does not have

import json
from datetime import date, datetime
from decimal import Decimal

data = {"when": date(2026, 8, 22), "amount": Decimal("19.99"), "tags": {"a", "b"}}

# json.dumps(data)      # TypeError: Object of type date is not JSON serializable

The default hook

import json
from datetime import date, datetime
from decimal import Decimal


def encode(value):
    if isinstance(value, (date, datetime)):
        return value.isoformat()
    if isinstance(value, Decimal):
        return str(value)
    if isinstance(value, (set, frozenset)):
        return sorted(value)
    raise TypeError(f"cannot serialise {type(value).__name__}")


data = {"when": date(2026, 8, 22), "amount": Decimal("19.99"), "tags": {"b", "a"}}
print(json.dumps(data, default=encode, indent=2))

A custom encoder class

import json
from dataclasses import dataclass, asdict, is_dataclass
from datetime import date


@dataclass
class Note:
    title: str
    created: date
    tags: list


class Encoder(json.JSONEncoder):
    def default(self, value):
        if is_dataclass(value):
            return asdict(value)
        if isinstance(value, date):
            return value.isoformat()
        return super().default(value)


note = Note("Regex", date(2026, 8, 22), ["python", "text"])
print(json.dumps(note, cls=Encoder, indent=2))

Decoding back into objects

import json
from datetime import date


def decode(pairs):
    result = dict(pairs)
    if "created" in result:
        result["created"] = date.fromisoformat(result["created"])
    return result


text = '{"title": "Regex", "created": "2026-08-22", "tags": ["python"]}'
record = json.loads(text, object_pairs_hook=decode)

print(record)
print(type(record["created"]))       # <class 'datetime.date'>

Working with nested JSON

import json

text = """
{
  "company": "Lumen Works",
  "departments": [
    {"name": "engineering", "staff": [
        {"name": "Meera", "years": 4},
        {"name": "Arun", "years": 2}
    ]},
    {"name": "design", "staff": [{"name": "Sara", "years": 6}]}
  ]
}
"""

data = json.loads(text)

everyone = [
    member
    for department in data["departments"]
    for member in department["staff"]
]

print(len(everyone), "people")
print(sum(m["years"] for m in everyone), "total years")
print(max(everyone, key=lambda m: m["years"])["name"])

for department in data["departments"]:
    names = ", ".join(m["name"] for m in department["staff"])
    print(f"{department['name']:<14}{names}")
def dig(data, *keys, default=None):
    """Follow a path through nested JSON without raising."""
    current = data
    for key in keys:
        try:
            current = current[key]
        except (KeyError, IndexError, TypeError):
            return default
    return current


print(dig(data, "departments", 0, "staff", 1, "name"))     # Arun
print(dig(data, "departments", 9, "name", default="-"))    # -

Streaming large files

import json

# JSON Lines: one complete JSON object per line
lines = [
    '{"id": 1, "amount": 100}',
    '{"id": 2, "amount": 250}',
    '{"id": 3, "amount": 75}',
]

total = 0
for line in lines:                       # in practice, iterate a file
    record = json.loads(line)
    total += record["amount"]

print(total)

json.load reads the whole document into memory, so a two gigabyte JSON array is a problem. The usual answer is JSON Lines: one object per line, read and parsed one line at a time.

A worked example

import json
from pathlib import Path


class Settings:
    """Settings backed by a JSON file, with defaults and validation."""

    DEFAULTS = {
        "theme": "light",
        "font_size": 12,
        "auto_save": True,
        "recent_files": [],
    }

    def __init__(self, path="settings.json"):
        self.path = Path(path)
        self.data = dict(self.DEFAULTS)
        self.load()

    def load(self):
        if not self.path.exists():
            return self
        try:
            loaded = json.loads(self.path.read_text(encoding="utf-8"))
        except json.JSONDecodeError as error:
            print(f"ignoring invalid settings: {error.msg}")
            return self
        for key, value in loaded.items():
            if key not in self.DEFAULTS:
                print(f"unknown setting ignored: {key}")
                continue
            if not isinstance(value, type(self.DEFAULTS[key])):
                print(f"wrong type for {key}, using the default")
                continue
            self.data[key] = value
        return self

    def save(self):
        temporary = self.path.with_suffix(".tmp")
        temporary.write_text(
            json.dumps(self.data, indent=2, sort_keys=True),
            encoding="utf-8",
        )
        temporary.replace(self.path)          # atomic
        return self

    def __getitem__(self, key):
        return self.data[key]

    def __setitem__(self, key, value):
        if key not in self.DEFAULTS:
            raise KeyError(f"unknown setting: {key}")
        self.data[key] = value


settings = Settings("demo-settings.json")
settings["theme"] = "dark"
settings["recent_files"] = ["notes.txt"]
settings.save()

print(Settings("demo-settings.json").data)

Common mistakes

  • Confusing dumps with dump. The s means a string.
  • Expecting tuples and sets to survive a round trip.
  • Expecting integer dictionary keys to stay integers.
  • Writing a trailing comma or a comment and producing invalid JSON.
  • Not catching JSONDecodeError on external data.
  • Loading a very large JSON array with json.load.
  • Assuming valid JSON means valid data; the structure still needs checking.

Best practices

  • Always pass encoding="utf-8" when opening JSON files.
  • Use indent=2 for human readable output and compact separators for transport.
  • Use sort_keys=True for files under version control.
  • Validate the loaded structure; do not trust its shape.
  • Write atomically through a temporary file when replacing a settings file.
  • Use JSON Lines for large or streamed data.

Practice

  1. Round trip a structure containing a tuple, a set and a date, and handle each properly.
  2. Write a loader that merges a JSON file over a set of defaults and reports unknown keys.
  3. Parse a nested JSON document and produce a flat report.
  4. Handle a corrupted JSON file gracefully, reporting the line number.
  5. Convert a list of dataclass instances to JSON and back.

Conclusion

Four functions, one type mapping and a few asymmetries: tuples become lists, keys become strings, and dates need a hook. Validate anything you did not write, and write settings files atomically.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Python notes →
Python

Trees and Graphs

A tree is a graph with no cycles and one root. Both are walked with the same two strategies - depth first with a stack, breadth first with a queue.

Read more
Python

Sorting Algorithms

Python sorts for you in n log n. Implementing bubble, insertion, merge and quick sort is still worth doing, because it teaches how algorithms are comp...

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.