json
JSON to Pydantic Models: How One Sample Lies to the Generator
Why auto-generated Pydantic models are a first draft, not a schema: int vs float, invisible optionality, empty arrays, stringly-typed dates, camelCase aliases, and the v1/v2 traps — with hardened before/after code.
Paste a JSON response into a converter, get a Pydantic model back, ship it — and three days later production throws ValidationError on a payload that looked exactly like your sample. The tool didn’t malfunction. A JSON sample is a set of observations, not a schema, and no generator — ours included — can infer constraints that the sample never exhibits. One payload can’t tell you that a field is sometimes absent, that an integer-looking price is secretly a float, or what lives inside an array that happened to arrive empty.
This post walks through every place sample-based inference is forced to guess, what our JSON to Pydantic converter actually emits in each case, and how to harden the draft into a model you can trust. The behaviors below are Pydantic v2, checked against the current Pydantic docs.
What the generator can and cannot see
A generator sees values, not intent. From 412 it can conclude “integer here, this time.” It cannot conclude “always an integer,” “never null,” or “always present.” Every column in this table is a guess forced by missing evidence:
| Your sample shows | Generator must assume | What can actually arrive |
|---|---|---|
1 | int | 1.5 — and Pydantic v2 rejects it |
| Field present in every object | required | absent |
null | nullable, type unknown | any type at all |
[] | list[Any] | list[str], list[dict], anything |
[1, 2.5] | list[float] | fine — one of the few merges that’s safe |
"2026-08-19T09:30:00Z" | str | a timestamp you wanted parsed |
| Only the fields you sampled | that’s all of them | extra fields, silently ignored by default |
Let’s take them one at a time.
int vs float: the guess that bites hardest
JSON has one number type. Python has two, and the sample can’t tell you which the API means. A price of 12 infers int; the day someone buys a $12.50 item, 12.5 arrives — and in Pydantic v2’s default (lax) mode, that’s a hard error, not a quiet truncation. The docs state the rule exactly: floats are validated as integers only “provided the float input is not infinite or a NaN… and the fractional part is 0”. So 12.0 passes an int field, 12.5 raises ValidationError.
It’s worse than it looks, because the sample itself can hide the evidence: JSON parsers hand the generator a language-native number, and 12.0 parses to plain 12 in JavaScript. Even a sample that spells out the decimal point can still infer int. The reverse direction is safe — int input is accepted by a float field — which gives you the cheap fix: if a numeric field could ever carry a fraction, widen it to float (or better, Decimal for money) before you ship. Our tool only emits float when it actually sees a fractional value somewhere in the sample; treat every emitted int on API-controlled numbers as a question, not an answer.
Optionality is invisible; null is only half a fact
Two separate lies hide in field presence:
A field present in your sample may be absent in the next payload. Nothing in one JSON object signals “sometimes missing.” When you paste an array of objects, our generator does compare them — a key missing from some objects gets | None plus a = None default — but a single object, or an array where every element happens to be fully populated, gives it nothing to work with. Every field comes out required.
A null in your sample proves the field is nullable — and tells you nothing about its real type. "avatarUrl": null could be a nullable string, a nullable object, a nullable list. Our generator is honest about that ignorance: a field it has only ever seen as null becomes Any | None, which is a flag for you to fill in, not a type.
One subtlety our output preserves that hand-written models often get wrong: in Pydantic v2, nullable and optional are independent. str | None alone means “the key must be present, but may be null” — the field is still required. Only a default (= None) makes the key itself omittable. This changed from v1, where the migration guide is blunt: a field annotated Optional[T] “will be required, and will allow for a value of None. It does not mean that the field has a default value of None.” Our tool mirrors your sample precisely — null values get | None but stay required; missing keys get | None and a default.
Empty arrays and mixed arrays
"tags": [] carries zero information about element type, so our generator emits list[Any] — which validates literally anything, including the garbage you built the model to catch. Same for a field that was null in every sampled object. Both Anys are the generator saying “you know something I don’t.”
Mixed arrays merge conservatively: [1, 2.5] becomes list[float] (the one safe widening), but any other mix — [1, "two"] — collapses to list[Any]. If your API really returns heterogeneous arrays, the honest type is an explicit union like list[int | str]; write it yourself.
Stringly-typed data: dates, UUIDs, and money
JSON has no date, UUID, or decimal type — they all travel as strings, so str is what a sample-faithful generator emits. Pydantic is exactly where you should upgrade them, because lax mode coerces strings into rich types at validation time: annotate datetime, UUID, or Decimal and "2026-08-19T09:30:00Z" becomes a real timezone-aware datetime, with malformed values rejected instead of smuggled through as text. (Even in strict mode this survives for JSON input — the docs note “the date and time types allow strings even in strict mode” when validating JSON, since there’s no other way to represent them.) Our tool won’t guess formats from string shape — a ten-digit string of digits might be a phone number, not a timestamp — so this upgrade is always yours to make.
camelCase JSON, snake_case Python
APIs speak createdAt; PEP 8 wants created_at. Our generator converts every key to snake_case and, wherever the name changed, attaches the alias so validation still reads the original key:
created_at: str = Field(alias="createdAt")
Three things to know about aliases in v2:
- By default, validation uses the alias.
Root(created_at=...)in Python code fails unless you enable name-based population. The current switch isvalidate_by_name=True(paired withvalidate_by_alias=True) inmodel_config; the olderpopulate_by_name=Truestill works but the docs now say it’s “not recommended in v2.11+ and will be deprecated in v3.” - Whole-model conversion has a shortcut.
ConfigDict(alias_generator=to_camel)withfrom pydantic.alias_generators import to_camelderives every alias instead of repeatingField(alias=...)per field; theAliasGeneratorclass lets validation and serialization use different rules. - Serialization follows suit.
model_dump(by_alias=True)round-trips back to camelCase — or setserialization_alias/serialize_by_aliaswhen input and output names differ.
Don’t paste v1 answers into v2 code
Half the Pydantic snippets on Stack Overflow are v1, and the config syntax is the giveaway. If you’re on v2 (you are, if you just generated a model with modern X | None syntax), translate on sight:
| Pydantic v1 | Pydantic v2 |
|---|---|
class Config: inner class | model_config = ConfigDict(...) |
allow_population_by_field_name | populate_by_name → validate_by_name (v2.11+) |
.dict() / .json() | .model_dump() / .model_dump_json() |
.parse_obj() / .parse_raw() | .model_validate() / .model_validate_json() |
orm_mode | from_attributes |
Optional[str] implies default None | no implicit default — add = None yourself |
That last row is the silent one: v1 code pasted into v2 doesn’t error, it just makes fields required that used to be optional.
Before and after: hardening the draft
Here’s a realistic sample and exactly what our tool generates from it:
{
"userId": 412,
"userName": "ada",
"plan": "pro",
"balance": 12,
"avatarUrl": null,
"tags": [],
"createdAt": "2026-08-19T09:30:00Z"
}
from typing import Any
from pydantic import BaseModel, Field
class Root(BaseModel):
user_id: int = Field(alias="userId")
user_name: str = Field(alias="userName")
plan: str
balance: int
avatar_url: Any | None = Field(alias="avatarUrl")
tags: list[Any]
created_at: str = Field(alias="createdAt")
A faithful transcription of one payload — and nearly every line embeds a guess. Hardened with what you know and the API docs confirm:
from datetime import datetime
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class User(BaseModel):
model_config = ConfigDict(
extra="forbid",
validate_by_name=True,
validate_by_alias=True,
)
user_id: int = Field(alias="userId")
user_name: str = Field(alias="userName")
plan: Literal["free", "pro"]
balance: Decimal
avatar_url: str | None = Field(alias="avatarUrl")
tags: list[str] = Field(default_factory=list)
created_at: datetime = Field(alias="createdAt")
Every change is evidence the sample couldn’t carry: plan narrowed to its legal values, balance widened past the int trap, both Anys resolved, the timestamp parsed instead of stored, tags given a default because the docs say it’s omitted for new users. And extra="forbid": Pydantic’s default is extra='ignore', which silently drops unknown keys — forgiving in production, but while you’re still learning an API’s shape, forbid turns every field your sample missed into a loud error instead of lost data. Strict mode (ConfigDict(strict=True), per-field Field(strict=True), or model_validate(..., strict=True)) is the same dial for type coercion: lax mode’s string-to-int conversions are convenient at the API boundary and dangerous when they mask upstream type drift.
The same limits apply in every language
None of this is Pydantic-specific. Our JSON to Zod and JSON to TypeScript converters run the same inference over the same evidence, so a Zod schema or TS interface generated from one sample inherits identical blind spots — unions it never saw, optionality it couldn’t detect, z.any() and any where the sample went quiet. The fix is the same everywhere: generate the draft, then spend five minutes tightening it against what you actually know. The problem compounds with line-based formats: an NDJSON file is thousands of independent samples, and any one of them can carry the union variant or the null your inference never saw. And when the shape you’re formalizing is a database rather than an API payload, the same paste-what-you-have workflow applies — generate an ER diagram straight from existing SQL instead of hand-modeling it.
The checklist
Run a generated model past these six questions before it touches production:
- Any
intthat could ever be fractional →floatorDecimal. - Which fields can the API omit? Add
= None(or a real default) — presence in the sample proves nothing. - Every
Anyis a confession: fill in the real type for null-only fields and empty arrays. - Strings that are secretly dates, UUIDs, or money →
datetime,UUID,Decimal. - Enum-shaped strings →
Literal[...]. - Decide
extraon purpose:forbidwhile exploring, the defaultignoreonce the shape is settled.
Paste a payload into the JSON to Pydantic converter — everything runs in your browser, nothing uploads — and treat what comes out as what it is: a first draft written by something that has only ever seen your data once.