Skip to content

07-03: Working with JSON Files

JSON (JavaScript Object Notation) is the universal format for structured data exchange — APIs, config files, databases, and web services all use it. Python's built-in json module handles it perfectly.


What is JSON?

{
  "name": "Alice",
  "age": 30,
  "is_active": true,
  "score": 88.5,
  "tags": ["python", "data-science"],
  "address": {
    "city": "New York",
    "zip": "10001"
  },
  "notes": null
}

JSON types map directly to Python:

JSON Python
object { } dict
array [ ] list
string "..." str
number (int) int
number (float) float
true / false True / False
null None

Reading JSON Files

import json

# Load from file
with open("data.json", "rt", encoding="utf-8") as f:
    data = json.load(f)

print(type(data))         # <class 'dict'>
print(data["name"])       # Alice
print(data["tags"])       # ['python', 'data-science']
print(data["address"]["city"])  # New York

# Load a JSON array from file
with open("students.json", "rt", encoding="utf-8") as f:
    students = json.load(f)   # list of dicts

for s in students:
    print(s["name"], s["score"])

Writing JSON Files

import json

data = {
    "name": "Alice",
    "age": 30,
    "scores": [85, 92, 78],
    "active": True,
    "notes": None,
}

# Basic write
with open("output.json", "wt", encoding="utf-8") as f:
    json.dump(data, f)

# Pretty-printed (human readable)
with open("output.json", "wt", encoding="utf-8") as f:
    json.dump(data, f, indent=2)

# Pretty-printed with sorted keys
with open("output.json", "wt", encoding="utf-8") as f:
    json.dump(data, f, indent=2, sort_keys=True)

# Compact (no extra spaces) — for smaller file size
with open("output.json", "wt", encoding="utf-8") as f:
    json.dump(data, f, separators=(",", ":"))

JSON Strings (in-memory)

When working with APIs or network data, JSON strings are handled rather than files:

import json

# Python dict → JSON string
data = {"name": "Bob", "age": 25}
json_string = json.dumps(data)
print(json_string)            # '{"name": "Bob", "age": 25}'
print(type(json_string))      # <class 'str'>

# Pretty
print(json.dumps(data, indent=2))

# JSON string → Python dict
text = '{"name": "Bob", "age": 25, "active": true}'
parsed = json.loads(text)
print(parsed)             # {'name': 'Bob', 'age': 25, 'active': True}
print(parsed["name"])     # Bob
print(type(parsed))       # <class 'dict'>

Working with Nested JSON

import json

json_str = """
{
  "company": "Acme Corp",
  "employees": [
    {"id": 1, "name": "Alice", "dept": "Engineering", "salary": 90000},
    {"id": 2, "name": "Bob",   "dept": "Marketing",   "salary": 70000},
    {"id": 3, "name": "Carol", "dept": "Engineering", "salary": 85000}
  ],
  "location": {
    "city": "New York",
    "country": "USA"
  }
}
"""

data = json.loads(json_str)

# Access nested data
print(data["company"])                      # Acme Corp
print(data["location"]["city"])             # New York
print(data["employees"][0]["name"])         # Alice

# Iterate employees
for emp in data["employees"]:
    print(f"{emp['name']}{emp['dept']} — ${emp['salary']:,}")

# Filter
engineers = [e for e in data["employees"] if e["dept"] == "Engineering"]
avg_salary = sum(e["salary"] for e in engineers) / len(engineers)
print(f"Avg engineer salary: ${avg_salary:,.0f}")

# Add new employee
data["employees"].append({"id": 4, "name": "Dave", "dept": "HR", "salary": 65000})

# Save back
with open("company.json", "wt", encoding="utf-8") as f:
    json.dump(data, f, indent=2)

Error Handling

import json

def safe_load_json(path):
    """Load JSON with proper error handling."""
    try:
        with open(path, "rt", encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        print(f"File not found: {path}")
        return None
    except json.JSONDecodeError as e:
        print(f"Invalid JSON in {path}: {e.msg} (line {e.lineno})")
        return None

def safe_parse_json(text):
    """Parse JSON string with error handling."""
    try:
        return json.loads(text)
    except json.JSONDecodeError as e:
        print(f"JSON parse error: {e}")
        return None

# Test
result = safe_parse_json('{"valid": true}')       # works
result = safe_parse_json('{invalid json here}')   # handled gracefully

Custom JSON Encoding

By default, json.dumps cannot serialize Python objects like datetime, set, Decimal, etc. A custom encoder is needed:

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

class CustomEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, (datetime, date)):
            return obj.isoformat()          # "2024-06-09T15:30:00"
        if isinstance(obj, Decimal):
            return float(obj)
        if isinstance(obj, set):
            return sorted(list(obj))        # convert set to sorted list
        if isinstance(obj, bytes):
            return obj.decode("utf-8")
        return super().default(obj)

data = {
    "event": "launch",
    "timestamp": datetime.now(),
    "price": Decimal("9.99"),
    "tags": {"python", "web"},
}

print(json.dumps(data, cls=CustomEncoder, indent=2))

Simpler alternative — use default parameter

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

print(json.dumps({"ts": datetime.now()}, default=json_serializer))

Custom JSON Decoding

When loading, convert specific strings back to Python types:

import json
from datetime import datetime

def decode_datetime(d):
    """Restore ISO datetime strings back to datetime objects."""
    for key, value in d.items():
        if isinstance(value, str):
            try:
                d[key] = datetime.fromisoformat(value)
            except ValueError:
                pass
    return d

json_str = '{"name": "Alice", "joined": "2024-01-15T10:30:00"}'
data = json.loads(json_str, object_hook=decode_datetime)
print(data["joined"])         # 2024-01-15 10:30:00
print(type(data["joined"]))   # <class 'datetime.datetime'>

JSON Lines Format (JSONL)

JSONL (.jsonl) stores one JSON object per line — common for large datasets, logs, and streaming:

{"id": 1, "name": "Alice", "score": 88.5}
{"id": 2, "name": "Bob", "score": 92.0}
{"id": 3, "name": "Charlie", "score": 79.3}
import json

# Read JSONL
def read_jsonl(path):
    records = []
    with open(path, "rt", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if line:
                records.append(json.loads(line))
    return records

# Write JSONL
def write_jsonl(path, records, mode="wt"):
    with open(path, mode, encoding="utf-8") as f:
        for record in records:
            f.write(json.dumps(record) + "\n")

students = [
    {"id": 1, "name": "Alice", "score": 88.5},
    {"id": 2, "name": "Bob",   "score": 92.0},
]
write_jsonl("students.jsonl", students)
loaded = read_jsonl("students.jsonl")
print(loaded)

Working with JSON APIs

import json
import urllib.request   # built-in, no pip needed

def get_json(url):
    """Fetch JSON from a URL."""
    try:
        with urllib.request.urlopen(url) as response:
            raw = response.read().decode("utf-8")
            return json.loads(raw)
    except Exception as e:
        print(f"Request failed: {e}")
        return None

# Fetch a public API
data = get_json("https://jsonplaceholder.typicode.com/users/1")
if data:
    print(data["name"])
    print(data["email"])

# With requests library (more convenient, pip install requests)
import requests

response = requests.get("https://jsonplaceholder.typicode.com/posts")
posts = response.json()   # auto-parses JSON response
print(f"Got {len(posts)} posts")
print(posts[0]["title"])

Practical Patterns

Config file reader/writer

import json
import os

CONFIG_FILE = "config.json"

DEFAULT_CONFIG = {
    "debug": False,
    "log_level": "INFO",
    "max_retries": 3,
    "timeout": 30,
    "output_dir": "./output",
}

def load_config():
    """Load config, creating with defaults if missing."""
    if not os.path.exists(CONFIG_FILE):
        save_config(DEFAULT_CONFIG)
        return DEFAULT_CONFIG.copy()
    with open(CONFIG_FILE, "rt", encoding="utf-8") as f:
        config = json.load(f)
    # Merge with defaults (new keys get defaults)
    return {**DEFAULT_CONFIG, **config}

def save_config(config):
    with open(CONFIG_FILE, "wt", encoding="utf-8") as f:
        json.dump(config, f, indent=2)

config = load_config()
config["debug"] = True
save_config(config)

Cache / persistence

import json
import os
from datetime import datetime

CACHE_FILE = "cache.json"

def load_cache():
    if os.path.exists(CACHE_FILE):
        with open(CACHE_FILE, "rt") as f:
            return json.load(f)
    return {}

def save_cache(cache):
    with open(CACHE_FILE, "wt") as f:
        json.dump(cache, f, indent=2)

cache = load_cache()
key = "user_42"
if key not in cache:
    cache[key] = {"name": "Alice", "fetched_at": datetime.now().isoformat()}
    save_cache(cache)
print(cache[key])

Quick Summary

Task Code
File → Python json.load(file_obj)
String → Python json.loads(string)
Python → File json.dump(data, file_obj, indent=2)
Python → String json.dumps(data, indent=2)
Handle parse error except json.JSONDecodeError
Custom types cls=CustomEncoder or default=func
One object per line JSONL format

Exercises: 07-03: Exercises — JSON Files


⬅️ Previous: 07-02: Working with CSV Files ➡️ Next: 07-04: Working with Excel Files