From JSON to Pydantic Models: Runtime Validation You Can Trust
Turn any JSON payload into typed Pydantic BaseModel classes for FastAPI request bodies, nested models, and runtime validation that catches bad data.
From JSON to Pydantic Models: Runtime Validation You Can Trust
Most Python code that touches an external API starts the same lazy way: data = resp.json(), and then you reach into the dict with data["user"]["profile"]["id"]. It works until a vendor renames a key or sends null where you expected a string, and then it blows up three calls deep with a KeyError that tells you nothing about where the real problem is. Pydantic fixes this by giving you a typed object instead of a bag of strings, and the fastest way to get there is to convert the JSON straight into a model.
This post walks through how a JSON sample maps onto a Pydantic BaseModel, why the runtime validation matters more than a plain dataclass, and how nested objects and FastAPI request bodies fall out almost for free.
A BaseModel validates types at runtime
The single most important thing a Pydantic model does that a @dataclass does not: a BaseModel validates types at runtime. A dataclass annotation is a hint and nothing more. You can write age: int on a dataclass, pass it the string "forty", and Python will happily store the string. The mistake surfaces later, somewhere unrelated, when arithmetic finally chokes on it.
Pydantic checks the value the moment you construct the model. Construct a model expecting int with the value "forty" and you get a ValidationError immediately, with the field name, the bad value, and the reason. That is the whole point: the model is a gate at the boundary of your system. Garbage that comes in over the wire stops at the model instead of leaking into your business logic.
So when you paste JSON and pick a model generator, you are not just generating type hints for your editor. You are generating an actual checkpoint that runs every time the data arrives.
A worked example
Here is the kind of payload you get back from a typical user endpoint:
{
"id": 4021,
"login": "ada",
"score": 9.5,
"company": null,
"plan": { "name": "pro", "seats": 5 }
}
Paste that into the JSON to Pydantic generator, set the root name to GithubUser, choose Pydantic v2, and you get back:
from pydantic import BaseModel
from typing import Optional, Any
class Plan(BaseModel):
name: str
seats: int
class GithubUser(BaseModel):
id: int
login: str
score: float
company: Optional[Any] = None
plan: Plan
A few decisions are already made for you. id is int because the sample was a whole number, score is float because it carried a decimal, and company became Optional[Any] = None because it only ever appeared as null so the type cannot be inferred. The nested plan object lifted out into its own Plan class, written before the parent so the file reads top to bottom with no forward references. Now GithubUser.model_validate(resp.json()) gives you user.plan.seats with editor autocomplete instead of data["plan"]["seats"] and a prayer.
Why not a plain dataclass
I reach for a dataclass when the data is mine and already trustworthy: an internal config object, the return value of a function I wrote, a small bundle of fields passing between two functions in the same module. There is no boundary to police, so the validation overhead buys nothing.
The moment data crosses a boundary I do not control, the calculus flips. A webhook body, a third-party API response, a request hitting my endpoint from a browser, a row read out of a CSV someone emailed me. There, the dataclass is a liability because it lulls you into thinking the types are real when they are only annotations. Pydantic earns its keep at exactly these boundaries, and a converter saves you from hand-writing the model for a 30-field response you will only ever read once. For pure structural shapes with no validation needs, the lighter JSON to Python dataclass tool is the right call instead.
Nested models and merged array shapes
Real payloads nest, and the converter follows them as deep as they go. A tls block inside a service block inside the root each become their own class, each named after its key in PascalCase, each emitted before its parent. You never reconcile forward references by hand.
Arrays of objects get the smartest treatment. Say you log a few webhook payloads and paste them as an array:
[
{ "id": "a", "status": "active" },
{ "id": "b", "status": "active", "cancelled_at": "2026-06-01" }
]
A naive generator would emit two near-duplicate classes. This one folds the array into a single shared model and notices that cancelled_at is present in one element but missing in the other, so it marks that field Optional[str] = None. That is exactly the signal you want: in your handler, if payload.cancelled_at is not None cleanly separates a cancelled event from an active one, and a genuinely required field that goes missing still raises instead of silently passing through. Note the rule, though: a single object has no missing-key signal, so every field comes out required. Wrap one sample as [ ... ] when you want to probe optionality.
FastAPI request bodies for free
This is where Pydantic stops being a nicety and becomes infrastructure. FastAPI reads a Pydantic model in a route signature and turns it into automatic body validation, an OpenAPI schema, and interactive docs, all without a line of glue code.
Suppose a teammate sends you a sample body for a POST /orders they are building. Paste it, name the root CreateOrderRequest, pick v2, and drop the result into schemas.py:
from fastapi import FastAPI
app = FastAPI()
@app.post("/orders")
def create_order(order: CreateOrderRequest) -> CreateOrderResponse:
...
Now any request with a missing field or a wrong type gets a clean 422 response with a per-field error list, generated by Pydantic before your handler runs. The /docs page renders a form from the same model. You wrote no validation code; the model is the spec. Paste the response sample too as CreateOrderResponse, annotate the return type, and the docs describe both ends of the contract before either of you writes the handler body.
If your stack also has a TypeScript front end consuming that same endpoint, generate the matching client types from the same JSON with JSON to TypeScript interface so both sides of the wire agree on the shape.
A couple of things to watch
Two snags catch people. First, match the Pydantic version dropdown to the version in your requirements. The v2 output writes model_config = ConfigDict(populate_by_name=True) for aliases; v1 writes the older nested class Config. Copy v2 output into a project pinned to pydantic 1.x and the ConfigDict import fails on line one. Second, the generator uses strict JSON.parse, so comments, trailing commas, and unquoted keys all error out with a line and column. If your sample is hand-edited and messy, clean it through a JSON formatter first, then convert.
Beyond that, trust the type widening. If one sample shows 1 and another shows 1.5, the field widens to float on purpose. Narrowing it back to int makes Pydantic reject the valid 1.5 row, and a JSON 1 is a perfectly good Python float anyway. The whole value of generating the model is that these inference rules are consistent; let them do their job, paste, and ship the model.
Made by Toolora · Updated 2026-06-13