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)
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).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.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) |