One payload, four shapes of Python
JSON carries six types. Python carries a type system deep enough to describe all of them and then some. The gap between those two facts is where a hand written model class goes wrong, usually on the field somebody forgot was nullable. Generating the class from real data closes most of the gap in one pass, and the four output styles above cover the reasons you would want a class in the first place.
| Style | Runtime validation | Instance type | Reach for it when |
|---|---|---|---|
@dataclass | None | Object with attributes | The data already arrived clean and you want dot access plus a free __eq__ and __repr__ |
BaseModel | Full, on construction | Object with attributes | The JSON crosses a trust boundary, so a wrong type should raise before it reaches your logic |
TypedDict | None | Still a plain dict | The code already passes dicts around and you want the type checker to see the keys |
| Plain class | None | Object with attributes | The target runtime has no third party packages and you want an explicit from_dict |
The gap worth understanding is the third column. A TypedDict is a static annotation over a dict, so order["total"] keeps working and mypy learns the keys, with zero cost at runtime. A dataclass and a Pydantic model both give you order.total, and both need something to build them. Pydantic builds itself from the raw dict with Order.model_validate(payload). A dataclass does not, which is why the plain class option writes a from_dict for you and the dataclass option leaves construction to you.
How each JSON value is turned into a type hint
Type inference runs over the values, not the keys. Every sample of a field contributes, so the type widens as the generator reads more of your payload.
| What the JSON holds | Python type written | Reasoning |
|---|---|---|
"text" | str | Default for any string with no recognised format |
"2026-03-14T09:21:05Z" | datetime | Only when every sample of the field parses as a timestamp |
"2026-03-14" | date | Date with no time part, all samples matching |
"8f2b1c4e-0d3a-..." | UUID | Canonical 8-4-4-4-12 hex form |
42 | int | No fractional part anywhere in the samples |
42.0 or a mix | float | One decimal sample widens the whole field |
true | bool | Checked before number, since bool subclasses int in Python |
null with other values | Optional[T] | Null is folded into the type rather than replacing it |
null and nothing else | Optional[Any] | No sample ever showed the real type |
[] | list[Any] | An empty array carries no element type |
[1, "a"] | list[Union[int, str]] | Element types are unioned, not narrowed to the first |
{ ... } | A generated class | Named after the key that held it |
Generic collections are written as list[str] rather than List[str], which needs Python 3.9 or newer. The X | None toggle switches the rest of the syntax to PEP 604 and drops the Optional import, which needs 3.10. Leave it off and the output runs on 3.9.
What happens to an array of objects
This is the part most generators get wrong, and it is the reason to paste a whole response rather than one trimmed record. Every element of an array is folded into one shape. A key present in all of them stays required. A key present in some of them becomes optional.
"line-items": [{ "sku": "KB-88", "qty": 1, "price": 189 },{ "sku": "MS-12", "qty": 2,"price": 30.25, "giftWrap": true }]@dataclass
class LineItem:sku: str
qty: int
price: float
gift_wrap: Optional[bool] = NoneThree decisions landed in those six lines. The array key was singularised, so the class is LineItem and the field is list[LineItem]. The price field saw 189 and 30.25, so it widened to float. And giftWrap appeared once out of two entries, so it came through optional with a default.
Field order shifted too. In a dataclass, a field carrying a default cannot sit above one without, or Python raises TypeError at class creation time. Optional fields are moved to the bottom for that reason, which means the attribute order stops matching your JSON key order. If the order matters to you, use Pydantic, where defaults are free to sit anywhere.
Key names Python will not accept
JSON keys are arbitrary strings. Python attribute names are not. Four collisions come up constantly in real payloads, and each style handles them differently.
- Reserved words. A key called
class,from,importorlambdais a syntax error as a parameter name. The generator appends an underscore, givingclass_, which is the same convention the standard library uses. - Hyphens and dots.
line-itemsanduser.namebecomeline_itemsanduser_name. This is a rename, not an alias, in every style except Pydantic. - Leading digits. A key like
2fa_enabledgets anf_prefix, since no Python identifier starts with a number. - Names that collide after conversion. A payload holding
userId,user_idanduser-idproduces one snake_case name three times over. The second and third get numeric suffixes, and the notes panel says so, because at that point the payload itself needs looking at.
Only Pydantic keeps a record of the original key. Every renamed field gets Field(alias="originalKey") plus model_config = ConfigDict(populate_by_name=True) on the class, so the model accepts the wire name and the Python name both. A dataclass has nowhere to record the mapping, so if you rename fields you own the translation in your parsing code.
TypedDict sidesteps the problem by keeping your keys exactly as they are, since they are dict keys rather than identifiers. When a key is not a valid identifier, the class switches to the functional form Order = TypedDict("Order", { ... }), which accepts any string.
Where a single sample misleads you
Inference is a reading of the data in front of it, not a schema. Four failures are worth knowing before you paste the output into a repository.
- A nullable field that happened to be filled. If
couponheld a string in your one sample, it is typedstr, and the first null response breaks it. Paste several responses, including the empty ones. - An empty array.
list[Any]is the honest answer to[], and it is also useless. Find a payload where the array has entries. - Integers that are secretly decimals. A price of
189is anintto any JSON parser. If the field is money, it isDecimal, and no amount of sampling will tell the tool that. Fix money and precise measurements by hand. - Enum-shaped strings. A status field holding
"open"and"closed"is typedstr. Turning it into aLiteral["open", "closed"]or anEnumis a modelling choice, and guessing at it from two samples would be worse than leaving it alone.
If you need guarantees rather than a reading, generate a JSON Schema from the same payload and treat the schema as the source of truth. Inference from examples is a fast start, not a contract.
Duplicate shapes collapse into one class
Two objects with matching field names, matching types and matching optionality get one class between them. In the sample payload, customer and shipping both hold a name, an email and a flag, so both fields are typed Customer and no Shipping class is written.
customer: Customer
shipping: CustomerThis keeps the output readable on a large payload, where a naive generator would write forty near identical classes. It also occasionally merges two things you consider distinct. If shipping and customer are separate domain concepts in your code, split the class after generation. The tool is matching structure, and structure is all it has to go on.
A working example, start to finish
Assume you are wiring up an API client and want the response validated before it reaches your code.
- Call the endpoint and copy a full response body, ideally two or three of them concatenated into an array so optional fields reveal themselves.
- Paste it above, set the root class name to match the resource, and switch the output to Pydantic v2.
- Read the notes panel. Anything marked as a warning is a field where the payload did not give enough information.
- Download the file into your project, then fix the two or three fields the data could not describe: money to
Decimal, status strings toLiteral, and any array that came through aslist[Any]. - Parse with
Order.model_validate(response.json())and let Pydantic raise on anything the endpoint sends that does not match.
Step four is the one people skip. The generator gets you to roughly ninety percent of a usable model in a second. The last ten percent is domain knowledge no tool reads off a payload.
What this converter does not do
- No validation rules. String lengths, numeric ranges, regex patterns and required combinations are absent from the output. Pydantic supports all of them through
Fieldconstraints, and you add them yourself. - No
Decimal,EnumorLiteral. Each is a modelling decision rather than something visible in the data. Guessing would produce classes you have to unpick. - No attrs, msgspec or SQLAlchemy output. The four styles above cover the standard library and the one third party library most projects already carry.
- No recursive structures. A tree where a node contains nodes of its own type generates a nested class per level rather than a self reference. Collapse it by hand with a forward reference such as
children: list["Node"]. - Comments and key order are lost. Strict JSON has no comments to lose, and Python class attributes carry no memory of source order beyond what the generator writes.
- Very large payloads slow the tab down. Parsing and generation run in your browser on the main thread. Files in the low megabytes are fine. A hundred megabyte export is not, and a trimmed sample of it would give you the same classes anyway.
Nothing you paste is uploaded. The parser and the generator both run in this page, so a payload with production data in it stays on your machine. Load the page once, drop your network connection, and everything above keeps working.
