JSON #

JSON (JavaScript Object Notation) is the most widely used data-exchange format in modern software development — from API responses, configuration files, to inter-service communication. Python provides the complete built-in json module for reading, writing, and manipulating JSON data without installing any extra library. Understanding how this module works deeply — including type mapping, error handling, and custom object serialization patterns — will save you from hard-to-trace bugs in production environments.

Python ↔ JSON Type Mapping #

Before getting started, it’s important to understand how Python and JSON map their data types to each other. Unexpected conversions are often a source of bugs that aren’t immediately visible.

In general, the interaction between Python data types and JSON data involves two main processes: Serialization (converting Python objects into JSON format) and Deserialization (reading JSON format back into Python objects). The workflow of both processes can be illustrated as follows:

flowchart LR
    subgraph Python ["Python Environment"]
        Obj["Python Object (dict, list, str, int, True, None)"]
    end

    subgraph Operations ["json Module"]
        direction TB
        dumps["json.dumps() / json.dump()"]
        loads["json.loads() / json.load()"]
    end

    subgraph JSON ["JSON Data"]
        JSONStr["JSON String / File (object, array, string, number, true, null)"]
    end

    Obj -->|"Serialization (dumps / dump)"| dumps
    dumps --> JSONStr

    JSONStr -->|"Deserialization (loads / load)"| loads
    loads --> Obj

Python → JSON:

Python Data TypeJSON Data Type
dictobject {}
list, tuplearray []
strstring ""
int, floatnumber
Truetrue
Falsefalse
Nonenull

JSON → Python:

JSON Data TypePython Data Type
object {}dict
array []list
string ""str
number (int)int
number (float)float
trueTrue
falseFalse
nullNone
Note: a Python tuple is converted into a JSON array, but when read back it becomes a Python list — not a tuple. If type ordering matters, don’t rely on a JSON round-trip to preserve tuple types.

Parsing JSON #

The json module provides two functions for reading JSON data: loads() for strings and load() for files. The difference is small but often mixed up.

import json

# json.loads() -- from a JSON string
json_string = '{"nama": "Budi", "usia": 28, "aktif": true}'
data = json.loads(json_string)

print(data["nama"])   # Budi
print(data["aktif"])  # True (not "true" -- already a Python bool)
print(type(data))     # <class 'dict'>

# json.load() -- from a file
with open("data.json", "r", encoding="utf-8") as f:
    data = json.load(f)
# ANTI-PATTERN: reading a JSON file without explicit encoding
with open("data.json", "r") as f:   # ✗ -- fails on Windows with non-ASCII characters
    data = json.load(f)

# CORRECT: always include encoding="utf-8"
with open("data.json", "r", encoding="utf-8") as f:  # ✓
    data = json.load(f)

Serializing to JSON #

Two functions for producing JSON: dumps() produces a string, dump() writes directly to a file.

import json

data = {
    "nama": "Budi",
    "usia": 28,
    "hobi": ["membaca", "coding"],
    "aktif": True,
    "alamat": None
}

# To a JSON string (compact)
json_compact = json.dumps(data)
print(json_compact)
# {"nama": "Budi", "usia": 28, "hobi": ["membaca", "coding"], "aktif": true, "alamat": null}

# To a JSON string (pretty print)
json_pretty = json.dumps(data, indent=2, sort_keys=True, ensure_ascii=False)
print(json_pretty)
# {
#   "aktif": true,
#   "alamat": null,
#   "hobi": ["membaca", "coding"],
#   "nama": "Budi",
#   "usia": 28
# }

# Directly to a file
with open("output.json", "w", encoding="utf-8") as f:
    json.dump(data, f, indent=2, ensure_ascii=False)
ensure_ascii=False — this parameter is important if your data contains non-ASCII characters like accented letters, Arabic characters, or Indonesian characters. Without it, those characters get escaped into \uXXXX, which is still valid JSON but hard to read.

Error Handling #

Parsing JSON from external sources (APIs, user-uploaded files, form input) should always be wrapped in error handling. Invalid data should be handled explicitly, not left to crash.

import json

# ANTI-PATTERN: parsing without error handling
data = json.loads(user_input)  # ✗ -- crashes if the input isn't valid JSON

# CORRECT: handle JSONDecodeError explicitly
def parse_json_safe(raw: str) -> dict | None:
    try:
        return json.loads(raw)
    except json.JSONDecodeError as e:
        print(f"Invalid JSON: {e.msg} (line {e.lineno}, column {e.colno})")
        return None

# Usage example
result = parse_json_safe('{"nama": "Budi"')  # incomplete JSON
# Output: Invalid JSON: Expecting ',' delimiter (line 1, column 16)
# Returns: None

result = parse_json_safe('{"nama": "Budi"}')
# Returns: {"nama": "Budi"}
# Validate before processing
def is_valid_json(raw: str) -> bool:
    try:
        json.loads(raw)
        return True
    except json.JSONDecodeError:
        return False

print(is_valid_json('{"a": 1}'))   # True
print(is_valid_json('{a: 1}'))     # False -- keys without quotes aren't valid JSON
print(is_valid_json('null'))       # True -- null is valid JSON
print(is_valid_json(''))           # False

Serializing Custom Objects #

By default, json.dumps() can only handle Python’s built-in types. Custom class objects, datetime, Decimal, and set will cause a TypeError. There are two ways to deal with this.

Using the default Parameter #

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

# ANTI-PATTERN: serializing an unsupported object directly
from datetime import datetime
data = {"waktu": datetime.now()}
json.dumps(data)  # ✗ -- TypeError: Object of type datetime is not JSON serializable

# CORRECT: use a default function
def json_serializer(obj):
    if isinstance(obj, (datetime, date)):
        return obj.isoformat()
    if isinstance(obj, Decimal):
        return float(obj)
    if isinstance(obj, set):
        return list(obj)
    raise TypeError(f"Type {type(obj).__name__} can't be serialized")

data = {
    "nama": "Budi",
    "dibuat": datetime(2024, 3, 15, 10, 30),
    "harga": Decimal("99999.99"),
    "tag": {"python", "backend"}
}

result = json.dumps(data, default=json_serializer, indent=2, ensure_ascii=False)
print(result)
# {
#   "nama": "Budi",
#   "dibuat": "2024-03-15T10:30:00",
#   "harga": 99999.99,
#   "tag": ["python", "backend"]
# }

Using a Custom JSONEncoder #

For larger projects, subclassing JSONEncoder is cleaner:

import json
from datetime import datetime
from decimal import Decimal

class AppJSONEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, datetime):
            return obj.isoformat()
        if isinstance(obj, Decimal):
            return float(obj)
        if isinstance(obj, set):
            return sorted(list(obj))  # sort for consistent output
        return super().default(obj)

data = {
    "transaksi_id": "TRX-001",
    "waktu": datetime(2024, 3, 15, 10, 30),
    "total": Decimal("150000.00"),
    "kategori": {"makanan", "minuman"}
}

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

Deserializing into Custom Objects #

object_hook lets you convert every dict parsed from JSON into a custom Python object. This is useful for directly getting method-rich objects instead of raw dicts.

import json
from datetime import datetime
from dataclasses import dataclass

@dataclass
class Produk:
    nama: str
    harga: float
    stok: int

def dict_to_produk(d: dict):
    # Only convert if all the required fields are present
    if {"nama", "harga", "stok"}.issubset(d.keys()):
        return Produk(
            nama=d["nama"],
            harga=d["harga"],
            stok=d["stok"]
        )
    return d

json_string = '{"nama": "Laptop Gaming", "harga": 15000000.0, "stok": 5}'
produk = json.loads(json_string, object_hook=dict_to_produk)

print(type(produk))    # <class 'Produk'>
print(produk.nama)     # Laptop Gaming
print(produk.harga)    # 15000000.0

Serializing Dataclasses #

Since Python 3.7, dataclass is the modern way to define data classes. For JSON serialization, use dataclasses.asdict():

import json
from dataclasses import dataclass, asdict, field
from typing import List

@dataclass
class Alamat:
    kota: str
    provinsi: str

@dataclass
class Pengguna:
    nama: str
    usia: int
    email: str
    alamat: Alamat
    hobi: List[str] = field(default_factory=list)

pengguna = Pengguna(
    nama="Budi Santoso",
    usia=28,
    email="[email protected]",
    alamat=Alamat(kota="Bandung", provinsi="Jawa Barat"),
    hobi=["membaca", "hiking"]
)

# Convert to a dict first, then to JSON
data_dict = asdict(pengguna)
json_string = json.dumps(data_dict, indent=2, ensure_ascii=False)
print(json_string)
# {
#   "nama": "Budi Santoso",
#   "usia": 28,
#   "email": "[email protected]",
#   "alamat": {
#     "kota": "Bandung",
#     "provinsi": "Jawa Barat"
#   },
#   "hobi": ["membaca", "hiking"]
# }

Common Patterns in Real Applications #

Reading Configuration from a JSON File #

import json
import os
from pathlib import Path

def load_config(config_path: str = "config.json") -> dict:
    path = Path(config_path)
    
    if not path.exists():
        raise FileNotFoundError(f"Config file not found: {config_path}")
    
    with open(path, "r", encoding="utf-8") as f:
        try:
            config = json.load(f)
        except json.JSONDecodeError as e:
            raise ValueError(f"Invalid config.json format: {e}")
    
    return config

# config.json
# {
#   "database": {
#     "host": "localhost",
#     "port": 5432,
#     "name": "myapp"
#   },
#   "debug": false,
#   "allowed_hosts": ["localhost", "127.0.0.1"]
# }

config = load_config()
db_host = config["database"]["host"]  # localhost

Storing and Reading Cache Data #

import json
from pathlib import Path
from datetime import datetime

CACHE_FILE = Path("cache.json")

def save_cache(data: dict) -> None:
    payload = {
        "timestamp": datetime.now().isoformat(),
        "data": data
    }
    with open(CACHE_FILE, "w", encoding="utf-8") as f:
        json.dump(payload, f, indent=2, ensure_ascii=False)

def load_cache() -> dict | None:
    if not CACHE_FILE.exists():
        return None
    
    with open(CACHE_FILE, "r", encoding="utf-8") as f:
        try:
            payload = json.load(f)
            return payload.get("data")
        except json.JSONDecodeError:
            CACHE_FILE.unlink()  # delete the corrupted cache
            return None

Processing JSON API Responses #

import json

# Simulated API response
api_response = '''
{
    "status": "success",
    "data": {
        "users": [
            {"id": 1, "nama": "Budi", "aktif": true},
            {"id": 2, "nama": "Sari", "aktif": false}
        ],
        "total": 2
    }
}
'''

response = json.loads(api_response)

# ANTI-PATTERN: accessing nested dicts without a guard
nama = response["data"]["users"][0]["nama"]  # ✗ -- KeyError if the structure changes

# CORRECT: use .get() for safe access
users = response.get("data", {}).get("users", [])
active_users = [u for u in users if u.get("aktif", False)]

for user in active_users:
    print(f"ID: {user.get('id')}, Name: {user.get('nama')}")
# Output: ID: 1, Name: Budi

When NOT to Use the Built-in json Module #

Keep using the built-in json module when:
  ✓ Working with standard JSON structures
  ✓ No extreme performance requirements
  ✓ You want zero extra dependencies
  ✓ Simple data serialization/deserialization

Consider alternative libraries when:
  ✗ You need JSON schema validation (use jsonschema)
  ✗ You need high performance parsing of large JSON (use orjson or ujson)
  ✗ Working with Pydantic models (use model.model_dump_json())
  ✗ You need automatic class serialization without boilerplate (use cattrs or marshmallow)

Summary #

  • json.loads() vs json.load()loads() from a string, load() from a file object. Don’t mix them up.
  • json.dumps() vs json.dump()dumps() produces a string, dump() writes directly to a file.
  • Always encoding="utf-8" — include it when opening JSON files to avoid non-ASCII character issues across OSes.
  • ensure_ascii=False — use it so Indonesian/non-Latin characters aren’t escaped into \uXXXX.
  • JSONDecodeError — always wrap parsing from external sources in try/except to handle invalid input.
  • Custom encoders — use the default parameter or a JSONEncoder subclass to serialize datetime, Decimal, set, or custom objects.
  • object_hook — use it to automatically convert parsed dicts into custom Python objects.
  • dataclasses.asdict() — the cleanest way to serialize dataclasses to JSON.
  • .get() for nested access — protect dict access from external JSON with .get() to avoid KeyError if the structure changes.

← Previous: Mocking   Next: YAML →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact