Validating API data at the boundary (httpx/pydantic preview)
Fetch external JSON and validate it into a typed model at the boundary.
Fetching and validating external data
Why the boundary is where bugs get caught
An external API is code you do not control. It can rename a field, send "1" where you expected 1, drop active entirely, or add junk you never asked for. If that raw JSON flows deep into your program, a wrong type surfaces as a crash three functions away from the real cause. The discipline that prevents this: fetch, then immediately turn the untrusted dict into a typed object you can trust. Everything downstream then works with clean, known values.
httpx: fetch the raw JSON
httpx is the modern HTTP client (sync or async, same API):
import httpx
response = httpx.get("https://api.example.com/users/1")
response.raise_for_status() # raise on 4xx/5xx instead of parsing an error page
raw = response.json() # a plain dict, still untrusted
response.json() gives you a dict. Nothing about it is validated yet. The types are whatever the server chose to send.
pydantic validates and coerces
In production you hand that dict to a pydantic model. pydantic reads the declared field types, coerces where it is safe, and raises ValidationError where it is not:
from pydantic import BaseModel
class User(BaseModel):
id: int
name: str
active: bool
User(**raw) # "1" becomes 1, 1 becomes True, a missing field raises ValidationError
This sandbox: a dataclass plus explicit coercion
| field | raw | raw type | coerced | coerced type |
|---|---|---|---|---|
| id | '1' | str | 1 | int |
| name | 'Ada' | str | 'Ada' | str |
| active | 1 | int | True | bool |
There is no network and no pydantic here, so you do the same job by hand with a @dataclass. That difference matters: a @dataclass gives you the shape, but its type annotations are not enforced at runtime. Building a plain dataclass with id="1" stores the string "1" with no error at all. So you coerce each field yourself, exactly like the demo below:
raw = {"id": "1", "name": "Ada", "active": 1}
User(id=int(raw["id"]), name=str(raw["name"]), active=bool(raw["active"]))
# User(id=1, name='Ada', active=True)
Reach for raw["id"] (indexing), not raw.get("id"). Indexing raises KeyError on a missing field, which is the "a missing field should raise" behavior the Practice wants. .get would silently hand you None and push the failure downstream.
Pitfall: bool() of a string is almost always True
bool(1) is True and bool(0) is False, so coercing a 0/1 flag works. But bool of any non-empty string is True: bool("false") is True, and even bool("0") is True. If the API ever sends active as the string "false", a naive bool() silently flips it to True. Know your source's shape, and when a flag can arrive as text, map it explicitly (for example raw["active"] in (1, "1", "true", True)) instead of trusting bool().
Interview nuance: Python type hints are not enforced at runtime. id: int on a @dataclass is documentation the interpreter ignores. The constructor will happily store a str in that field. Runtime guarantees come only from something that actually checks, like pydantic, or from explicit coercion you write yourself. That is the whole reason the "validate at the boundary" pattern exists: annotations describe intent, boundary code enforces it.
from dataclasses import dataclass
@dataclass
class User:
id: int
name: str
active: bool
raw = {"id": "1", "name": "Ada", "active": 1}
print(User(id=int(raw["id"]), name=str(raw["name"]), active=bool(raw["active"])))Apply
Your turn
The task this lesson builds to.
Warm-up (one file): implement parse_user(raw). Coerce a raw user dict into a clean dict with
id as an int, name as a str, and active as a bool.
For {"id": "1", "name": "Ada", "active": 1} return {"id": 1, "name": "Ada", "active": True}.
2 hints and 3 automated checks are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, so it survives past today.
Implement parse_user(raw) in api/models.py: validate a raw user dict into the typed User
dataclass, coercing id to int, name to str, and active to bool. A missing field
should raise. Some tests are hidden.
3 hints and 2 automated checks are waiting in the workspace.