JSON Deep Dive — Serialisation, Schemas and Validation

JSON is the universal language of web APIs — every request body your FastAPI application receives and every response it sends is JSON. Python’s standard json module handles the basics, but production applications need more: custom serialisers for types the json module cannot handle (dates, UUIDs, Decimal), validation to ensure incoming data matches the expected shape, and field renaming to align Python’s snake_case conventions with JavaScript’s camelCase APIs. Understanding the full JSON pipeline — from raw string to validated Python object and back — is the foundation for everything FastAPI does automatically with Pydantic.

JSON Serialisation — Handling All Python Types

import json
from datetime import datetime, date
from decimal import Decimal
from uuid import UUID
from pathlib import Path

# ── Standard types — work out of the box ──────────────────────────────────────
data = {"name": "Alice", "age": 30, "active": True, "score": 3.14}
json.dumps(data)   # works fine

# ── Types that need custom handling ───────────────────────────────────────────
complex_data = {
    "id":         UUID("a8098c1a-f86e-11da-bd1a-00112444be1e"),
    "created_at": datetime(2025, 8, 6, 14, 30, 0),
    "birth_date": date(1990, 6, 15),
    "price":      Decimal("19.99"),
    "upload_dir": Path("/uploads/images"),
}
# json.dumps(complex_data)  ← TypeError: Object of type UUID is not JSON serialisable

# ── Custom serialiser function ────────────────────────────────────────────────
def json_default(obj):
    """Fallback serialiser for types the json module cannot handle."""
    if isinstance(obj, (datetime,)):
        return obj.isoformat()
    if isinstance(obj, date):
        return obj.isoformat()          # "1990-06-15"
    if isinstance(obj, UUID):
        return str(obj)                 # "a8098c1a-..."
    if isinstance(obj, Decimal):
        return float(obj)               # or str(obj) to preserve precision
    if isinstance(obj, Path):
        return str(obj)
    raise TypeError(f"Type {type(obj).__name__} is not JSON serialisable")

result = json.dumps(complex_data, default=json_default, indent=2)
print(result)
Note: FastAPI uses Pydantic’s JSON encoder automatically — it handles datetime, UUID, Decimal, Enum, and other types without any custom serialiser code in your route handlers. When you return a Pydantic model from a FastAPI route, Pydantic serialises it to a dict and FastAPI encodes it to JSON. You only need custom serialisers when using the json module directly (for logging, file writing, or cache storage).
Tip: Use json.dumps(data, indent=2, ensure_ascii=False) when writing JSON files. The ensure_ascii=False option preserves non-ASCII characters (Arabic, Chinese, accented Latin characters) as-is instead of escaping them as \uXXXX sequences — making the file human-readable for international content. The indent=2 makes the file readable by humans and diff-friendly in version control.
Warning: Never use Decimal values with json.dumps() and default=float for monetary amounts stored for persistent records. Converting Decimal("19.99") to float gives 19.990000000000001, losing the precision that was the whole reason for using Decimal. For monetary JSON: either use str(obj) in the serialiser (keeps “19.99” as a string) or multiply by 100 and store as an integer (cents).

Pydantic for JSON Validation

from pydantic import BaseModel, field_validator, model_validator
from typing import Optional, List
from datetime import datetime

class PostCreate(BaseModel):
    title:     str
    body:      str
    tags:      List[str]   = []
    published: bool        = False

class PostResponse(BaseModel):
    id:         int
    title:      str
    body:       str
    tags:       List[str]
    published:  bool
    created_at: datetime
    view_count: int

    model_config = {"from_attributes": True}   # allow from SQLAlchemy models

# Parse from JSON string
json_str = '{"title": "Hello", "body": "World", "tags": ["python"]}'
post = PostCreate.model_validate_json(json_str)   # validates and parses
print(post.title)    # Hello
print(post.tags)     # ["python"]
print(post.published)  # False (default)

# Parse from dict (from request body or database row)
post2 = PostCreate.model_validate({"title": "Hi", "body": "There"})

# Serialise to dict
post_dict = post2.model_dump()
# {"title": "Hi", "body": "There", "tags": [], "published": False}

# Serialise to JSON string
post_json = post2.model_dump_json()
# '{"title":"Hi","body":"There","tags":[],"published":false}'

# Serialise with options
post2.model_dump(exclude={"body"})         # exclude body field
post2.model_dump(include={"title", "tags"}) # only these fields
post2.model_dump(exclude_none=True)        # skip None values

Field Aliases — snake_case Python ↔ camelCase JSON

from pydantic import BaseModel, Field, AliasGenerator
from pydantic.alias_generators import to_camel

# Option 1: per-field alias
class UserResponse(BaseModel):
    first_name:  str = Field(alias="firstName")
    last_name:   str = Field(alias="lastName")
    created_at:  str = Field(alias="createdAt")

    model_config = {"populate_by_name": True}  # allow both names

user = UserResponse(first_name="Alice", last_name="Smith", created_at="2025-01-01")
user.model_dump(by_alias=True)
# {"firstName": "Alice", "lastName": "Smith", "createdAt": "2025-01-01"}

# Option 2: auto-generate camelCase aliases for all fields
class Post(BaseModel):
    model_config = {
        "alias_generator": to_camel,
        "populate_by_name": True,
    }

    post_id:    int
    created_at: str
    view_count: int

post = Post.model_validate({"postId": 1, "createdAt": "2025-01-01", "viewCount": 42})
post.model_dump(by_alias=True)
# {"postId": 1, "createdAt": "2025-01-01", "viewCount": 42}

# In FastAPI — return camelCase from all routes
app = FastAPI()

@app.get("/posts/{post_id}", response_model=Post)
async def get_post(post_id: int):
    return Post(post_id=post_id, created_at="2025-01-01", view_count=10)

JSON Diff and Merge Patterns

# Partial update — PATCH endpoint pattern
# Only update fields that are present in the request body

class PostUpdate(BaseModel):
    title:     Optional[str]  = None
    body:      Optional[str]  = None
    published: Optional[bool] = None
    tags:      Optional[List[str]] = None

def apply_patch(current: dict, patch: PostUpdate) -> dict:
    """Apply only non-None patch fields to current data."""
    update_data = patch.model_dump(exclude_none=True)   # only provided fields
    return {**current, **update_data}

current_post = {"id": 1, "title": "Old Title", "body": "Body", "published": False}
patch = PostUpdate(title="New Title")   # only title provided

updated = apply_patch(current_post, patch)
# {"id": 1, "title": "New Title", "body": "Body", "published": False}
# body and published unchanged ✓

Common Mistakes

Mistake 1 — Assuming json.loads returns typed Python objects

❌ Wrong — treating JSON string as a typed datetime:

data = json.loads('{"created_at": "2025-08-06T14:30:00"}')
expiry = data["created_at"] + timedelta(days=7)   # TypeError: str + timedelta

✅ Correct — parse the string to datetime explicitly (or use Pydantic):

created = datetime.fromisoformat(data["created_at"])
expiry  = created + timedelta(days=7)   # ✓

Mistake 2 — Using model_dump() without exclude_none for PATCH operations

❌ Wrong — None values overwrite existing data:

patch = PostUpdate(title="New")   # body, published are None
db_post.update(patch.model_dump())   # sets body=None, published=None in DB!

✅ Correct — exclude None fields:

db_post.update(patch.model_dump(exclude_none=True))   # only {title: "New"} ✓

Mistake 3 — Returning a plain dict from FastAPI without response_model validation

❌ Wrong — sensitive fields included in response:

@app.get("/users/{id}")
async def get_user(id: int):
    return db.query(User).get(id).__dict__   # includes _sa_instance_state, password_hash!

✅ Correct — use a Pydantic response_model:

@app.get("/users/{id}", response_model=UserResponse)
async def get_user(id: int):
    return db.query(User).get(id)   # Pydantic filters to only response_model fields ✓

Quick Reference

Task Code
Parse JSON string json.loads(text)
Serialise to JSON json.dumps(obj, default=handler)
Pydantic from JSON Model.model_validate_json(json_str)
Pydantic from dict Model.model_validate(data)
To dict model.model_dump(exclude_none=True)
To JSON string model.model_dump_json()
camelCase output model.model_dump(by_alias=True)
PATCH fields only patch.model_dump(exclude_none=True)

🧠 Test Yourself

A PATCH endpoint receives {"title": "New Title"}. Your Pydantic PostUpdate model has all fields as Optional with defaults of None. You call post.update(patch.model_dump()). What problem might occur?