Working with JSON Files in Python

Since its introduction, JSON has rapidly emerged as the predominant standard for the exchange of information — you'll encounter it constantly in APIs, configuration files, and document storage. This article covers Python's python json module in full: the four core functions, formatting options, handling types JSON doesn't natively support, and the errors worth guarding against when working with untrusted input.

Introduction: JSON as Python's Data Interchange Format

Why JSON is everywhere

JSON (JavaScript Object Notation) has become the dominant format for exchanging structured data across the web and beyond — REST APIs return it, configuration files are frequently written in it, and many document-oriented databases store data in a JSON-like format directly.

JSON's syntax vs. Python dictionaries

JSON's structure closely resembles Python dictionaries and lists, which makes it feel immediately familiar — but there are a few precise syntactic differences worth knowing:

{
    "name": "Alex",
    "age": 30,
    "is_active": true,
    "address": null
}
  • Only double quotes for strings — JSON doesn't accept single quotes at all, unlike Python, which allows either.

  • Lowercase booleanstrue and false, not Python's True and False.

  • null instead of None — JSON's equivalent of Python's None.

Python's json module handles all of these translations automatically in both directions, which is exactly the point of using it rather than trying to hand-roll JSON parsing yourself.

Four core functions, previewed

Python's standard-library json module covers nearly every use case through four functions, split cleanly between strings and files — covered in full in the next section.

The Four Core Functions: dump, dumps, load, loads

The memory-friendly rule of thumb

Functions ending in "s" (dumps, loads) work with strings. Functions without the "s" (dump, load) work with files (technically, any file-like object). This naming pattern — "dumps" as "dump string," "loads" as "load string" — is worth memorizing once, since it applies consistently across all four functions.

json.dumps(): Python object → JSON string
import json

data = {"name": "Alex", "age": 30, "is_active": True}
js json.dumps(data)
print(json_string)
print(type(json_string))
# {"name": "Alex", "age": 30, "is_active": true}
# <class 'str'>

dumps() (dump string) serializes a Python object into a JSON-formatted string — notice True correctly became true in the output, following JSON's own syntax rules.

json.dump(): Python object → directly into a file
import json

data = {"name": "Alex", "age": 30, "is_active": True}

with open("data.json", "w", encoding="utf-8") as f:
    json.dump(data, f)

dump() (no "s") writes the serialized JSON directly to a file object, rather than returning a string you'd have to write yourself — genuinely more convenient for the common case of "serialize this and save it to a file" in one step.

json.loads(): JSON string → Python object
import json

js '{"name": "Alex", "age": 30, "is_active": true}'
data = json.loads(json_string)
print(data)
print(type(data))
# {'name': 'Alex', 'age': 30, 'is_active': True}
# <class 'dict'>

loads() (load string) deserializes a JSON string — or bytes/bytearray — back into native Python objects: JSON objects become dictionaries, JSON arrays become lists, true/false become True/False, and null becomes None.

json.load(): directly from an open file → Python object
import json

with open("data.json", "r", encoding="utf-8") as f:
    data = json.load(f)

print(data)   # {'name': 'Alex', 'age': 30, 'is_active': True}
A round-trip example
import json

c {"debug": True, "max_retries": 3, "timeout": 30.5}

# Write it out
with open("config.json", "w", encoding="utf-8") as f:
    json.dump(config, f)

# Read it back
with open("config.json", "r", encoding="utf-8") as f:
    loaded_c json.load(f)

print(loaded_c= config)   # True — the round trip preserved everything correctly

Formatting Output: indent, sort_keys, and separators

The indent parameter for readability

By default, dumps()/dump() produce compact, single-line output — fine for machine-to-machine communication, but genuinely hard for a human to read:

import json

data = {"name": "Alex", "hobbies": ["reading", "hiking"], "age": 30}

print(json.dumps(data))
# {"name": "Alex", "hobbies": ["reading", "hiking"], "age": 30}

print(json.dumps(data, indent=2))
# {
#   "name": "Alex",
#   "hobbies": [
#     "reading",
#     "hiking"
#   ],
#   "age": 30
# }

indent=2 (or any integer) produces genuinely readable, pretty-printed output — worth using any time a human might actually need to open and read the resulting JSON file, like a config file or a debug log.

sort_keys for deterministic output
data = {"z_field": 1, "a_field": 2, "m_field": 3}
print(json.dumps(data, sort_keys=True))
# {"a_field": 2, "m_field": 3, "z_field": 1}

sort_keys=True guarantees keys always appear in the same, alphabetically sorted order, regardless of the order they existed in the original dictionary. This is genuinely useful in two specific situations: testing (comparing generated JSON output against an expected string reliably, without worrying about insertion-order differences) and hashing or diffing (ensuring the same logical data always produces byte-for-byte identical JSON, which matters if you're comparing or hashing the serialized output itself).

Custom separators for minimal output
data = {"name": "Alex", "age": 30}

print(json.dumps(data))
# {"name": "Alex", "age": 30}   — default separators include spaces

print(json.dumps(data, separators=(",", ":")))
# {"name":"Alex","age":30}   — no extra whitespace at all

The default separators include a space after commas and colons for readability. Passing separators=(",", ":") strips that whitespace entirely — a small but genuinely meaningful difference for minimizing file size or payload size in situations like network transmission, where every byte can matter at scale.

Handling Data Types JSON Doesn't Natively Support

The core limitation

JSON's type system is deliberately small: objects, arrays, strings, numbers, booleans, and null. Python objects that don't map cleanly onto one of these — datetime, Decimal, custom classes, set — cause json.dumps() to raise a TypeError:

import json
from datetime import datetime

data = {"created_at": datetime.now()}
json.dumps(data)
# TypeError: Object of type datetime is not JSON serializable
Solution 1: convert to a plain type before serializing

The simplest fix is converting the problematic value into something JSON already understands, before calling dumps():

import json
from datetime import datetime

data = {"created_at": datetime.now().isoformat()}   # convert to a string first
print(json.dumps(data))
# {"created_at": "2026-07-17T14:32:05.123456"}
Solution 2: a custom function via the default parameter

For more complex or recurring cases, dumps()/dump() accept a default parameter — a function called automatically whenever the encoder encounters an object it doesn't natively know how to serialize:

import json
from datetime import datetime
from decimal import Decimal

def custom_serializer(obj):
    if isinstance(obj, datetime):
        return obj.isoformat()
    if isinstance(obj, Decimal):
        return float(obj)
    raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")

data = {"created_at": datetime.now(), "price": Decimal("19.99")}
print(json.dumps(data, default=custom_serializer))
# {"created_at": "2026-07-17T14:32:05.123456", "price": 19.99}

For even more control — say, you're serializing the same custom types constantly across a whole project — you can subclass json.JSONEncoder directly and override its default() method, then pass that subclass via the cls parameter, which keeps the custom serialization logic bundled into a single, reusable class rather than a standalone function.

The reverse direction: object_hook

loads()/load() accept an object_hook parameter — a function called on every JSON object (dictionary) as it's parsed, letting you transform plain dictionaries back into richer Python types during deserialization:

import json
from datetime import datetime

def date_hook(d):
    if "created_at" in d:
        d["created_at"] = datetime.fromisoformat(d["created_at"])
    return d

js '{"created_at": "2026-07-17T14:32:05.123456"}'
data = json.loads(json_string, object_hook=date_hook)
print(type(data["created_at"]))   # <class 'datetime.datetime'>

This is the natural counterpart to the default parameter — default handles the "Python object → JSON" direction; object_hook handles "JSON → richer Python object" on the way back in, letting you round-trip custom types through JSON with both ends of the conversion handled explicitly.

Common Pitfalls and Best Practices

Error handling: json.JSONDecodeError

Parsing malformed or unexpected JSON raises json.JSONDecodeError, which is itself a subclass of ValueError:

import json

malformed = '{"name": "Alex", "age": }'   # missing value — invalid JSON

try:
    data = json.loads(malformed)
except json.JSONDecodeError as e:
    print(f"Invalid JSON: {e}")

Any time you're parsing JSON from an untrusted or unpredictable source — a file that might be corrupted, a network response that might have failed partway, user-uploaded data — wrapping the parsing call in try/except json.JSONDecodeError is genuinely important, exactly the same defensive principle covered throughout the earlier exception-handling articles in this series. Since JSONDecodeError is a ValueError subclass, catching ValueError broadly would also work, but catching the more specific JSONDecodeError directly is clearer about exactly what you're anticipating.

The dictionary-key gotcha

This genuinely surprises people the first time they hit it: JSON object keys are always strings — there's no other option in the JSON specification itself. This means round-tripping a Python dictionary with non-string keys through dumps()/loads() does not return an identical dictionary:

import json

data = {1: "one", 2: "two"}
js json.dumps(data)
print(json_string)   # {"1": "one", "2": "two"} — keys silently became strings

loaded = json.loads(json_string)
print(loaded)          # {'1': 'one', '2': 'two'} — integer keys are now strings!
print(data == loaded)   # False — not the same dictionary anymore

If your dictionary genuinely needs non-string keys preserved through a JSON round trip, you need to explicitly convert them back to their original type yourself after loading — json itself has no way to know your integer keys were ever meant to be integers again, since JSON's format simply doesn't distinguish.

Practical guidance

Always specify encoding="utf-8" when opening files for JSON containing any non-ASCII content — names, addresses, or text in languages beyond basic English — exactly the same reasoning covered in the earlier text files article: relying on a platform-dependent default encoding is a common, avoidable source of cross-platform bugs.

Consider a faster third-party library for performance-critical workloads. For very large JSON payloads, or performance-sensitive code parsing JSON repeatedly at scale, libraries like orjson offer meaningfully faster serialization and deserialization than the standard library's json module, at the cost of an external dependency. For the overwhelming majority of everyday scripts, config files, and typical API interactions, the built-in json module is entirely sufficient — reach for a faster alternative specifically once you've measured a genuine performance bottleneck tied to JSON processing itself, rather than adopting it preemptively.