Parsing JSON

Why model_validate_json beats json.loads plus model_validate - speed, error quality and fidelity.

Overview

The habit worth changing

Most people write this:

data = json.loads(raw)
model = Module.model_validate(data)

It works. The better form is:

model = Module.model_validate_json(raw)

It is not merely shorter. It is faster, produces better errors, and is more faithful with numbers. Three separate reasons, and they compound.

Worth knowing

model_validate_json parses and validates in one pass inside the Rust core, without building an intermediate Python dict.
Malformed JSON becomes a ValidationError with type json_invalid, so syntax and validation failures share one handler.
The error context includes the position in the document, which matters for a large payload where “invalid JSON” alone is useless.
Decimal is more faithful this way: the parser reads the digits as written, where json.loads has already produced an inexact float.
model_dump_json is the matching direction, and a JSON round trip is exact for every type Pydantic knows.
TypeAdapter(...).validate_json() gives the same benefits for bare arrays, dicts and other non-model shapes.

Parsing JSON: One Step Instead of Two

Why model_validate_json beats json.loads plus model_validate on speed, errors and fidelity.

One call instead of two

model_validate_json parses and validates together, inside the Rust core, without building an intermediate dict.

example_01.pyPydantic
Output

Malformed JSON is one exception, not two

Going via json.loads means catching a JSONDecodeError separately. The direct call folds it into ValidationError.

example_02.pyPydantic
Output

One handler for both failure kinds

Because syntax errors and validation errors arrive the same way, the code that deals with a bad request has one shape.

example_03.pyPydantic
Output

Decimals keep their exact text

The JSON parser sees the digits as written. Going through Python floats loses that before validation ever runs.

example_04.pyPydantic
Output

Dumping and reloading

The other direction is symmetric, and a JSON round trip is exact for every type Pydantic knows.

example_05.pyPydantic
Output

Bare arrays and other shapes

TypeAdapter gives the same one-step parsing for anything that is not a model.

example_06.pyPydantic
Output

Speed

The two-step version parses JSON into Python objects — dicts, lists, strings, floats — and then validates those objects, converting again into what the model wants.

Every intermediate object is allocated and thrown away. For a large payload that is a lot of garbage created for no purpose.

model_validate_json parses and validates in a single pass inside pydantic-core, the Rust engine. It reads the JSON text and constructs the final values directly, skipping the intermediate representation entirely.

The saving grows with payload size. For a small request body it is unimportant; for a large document, or a high-throughput endpoint, it is a real difference for a change that makes the code shorter.

Error quality

This is the argument that matters most day to day.

With json.loads, malformed JSON raises json.JSONDecodeError — a different exception, from a different library, that knows nothing about your model. Your handler needs two except clauses producing two shapes of error response, and the JSON one has no field information because there are no fields yet.

model_validate_json folds it in. Malformed JSON becomes a ValidationError with the type json_invalid, arriving through exactly the same channel as a missing field or a failed constraint.

That means one handler. The function that turns a ValidationError into a 422 response now covers syntax errors too, without a special case.

The error also carries the position in the document. For a missing comma four hundred lines into a config file, being told the line and column is the difference between a fix and a search.

Fidelity with numbers

The subtlest of the three, and the one that costs money.

json.loads turns 0.1 into a Python float, because that is what JSON numbers are in Python. The float is already inexact at that moment. Handing it to a Decimal field faithfully preserves the wrong value.

model_validate_json reads the characters. When the target is a Decimal, it can construct it from the exact text, so 0.1 in the document becomes Decimal("0.1") rather than the float's approximation.

For anything financial, that is not a micro-optimisation — it is the difference between correct and quietly wrong. It also means the advice from the types module ("send money as a string") has a companion: even when a partner sends money as a JSON number, parsing directly recovers more of it than going via Python.

The other direction

model_dump_json() is the matching call, and it is symmetric for the same reasons: it serialises from the model's data straight to text, without building an intermediate dict.

A round trip is exact for every type Pydantic knows. A date becomes an ISO string and parses back to the same date. A Decimal becomes a string and returns as the same Decimal. A UUID survives. This makes JSON a reasonable format for caching validated objects, and makes equality assertions in tests trustworthy.

Bytes work too

Both methods accept bytes as well as str, which is what an HTTP body actually is.

That saves a decode step, and avoids a class of bug where the wrong encoding is assumed. If you are reading a request body or a file, pass the bytes straight in.

Non-model shapes

Not every payload is an object. TypeAdapter provides the same one-step parsing for anything you can annotate:

TypeAdapter(List[Lesson]).validate_json(payload)
TypeAdapter(Dict[str, float]).validate_json(payload)

Same speed benefit, same error handling, same fidelity. And dump_json in the other direction, which returns bytes.

Build the adapter once at module level. Constructing one compiles a schema, and doing that inside a loop is a real and easily-missed performance mistake.

When to keep the two steps

There are legitimate reasons to parse separately.

You need the raw structure first. Inspecting a type field to choose a model, logging the payload, or routing on something before validating. Though a discriminated union often removes the first of those.

The input is not JSON. YAML, TOML and msgpack all produce Python objects, and model_validate is the right entry point for them.

You already have a dict from a database driver, another library, or your own code. There is nothing to parse.

The rule is simple: if you are holding JSON text or bytes, use model_validate_json. If you are holding Python objects, use model_validate. The mistake is converting text to objects yourself purely to hand them to a validator that would rather have had the text.

In practice with a framework

FastAPI already does this for request bodies, so an endpoint annotated with a model gets the fast path without you asking.

Where it matters in application code is everywhere else: reading a config file, consuming a queue message, calling another service and validating the response, loading a fixture in a test. Those are all places where the two-step habit is common and the one-step version is strictly better.

Strictness and JSON

The strict-mode module noted that a strict model validating from JSON still accepts the string forms JSON has no alternative to — a date as text, for instance.

That behaviour depends on Pydantic knowing the input was JSON, which it only does when you use model_validate_json. Parse with json.loads first and that context is gone: the model sees a Python string where a date was wanted, and in strict mode refuses it.

So the two-step form is not merely slower, it can be *stricter in the wrong way*. Another reason to hand the text straight over.

Large payloads

For very large documents, two things are worth knowing.

Parsing is a single pass and memory-bounded by the result rather than by intermediate objects, so model_validate_json uses meaningfully less memory than the two-step form on a big document.

There is no streaming. The whole document is parsed before validation completes, so a hundred-megabyte file is a hundred megabytes in memory. If that matters, the answer is at a different level — a streaming JSON reader producing records, each validated individually or in batches with a TypeAdapter.

Reading from a file

The natural spelling reads bytes and hands them straight over:

config = Settings.model_validate_json(Path("config.json").read_bytes())

No decode step, no json.load, and a malformed file produces a ValidationError naming the position rather than a JSONDecodeError from elsewhere.

For a config file that is a genuinely nice pattern: one call, one exception type, and errors that say which key was wrong and where in the file it was.

Other formats

Only JSON gets the fast path, because only JSON has a parser inside the core.

YAML, TOML and msgpack all go through their own libraries, which produce Python objects, which then go to model_validate. That is the correct shape for those formats and there is nothing to optimise — but it does mean a YAML config gets none of the fidelity benefit for decimals, and a syntax error arrives as that library's exception.

If exactness matters in a YAML config, quoting the number so it arrives as a string is the pragmatic fix.

What to take away

The habit is small: when you are holding JSON text or bytes, hand it to Pydantic rather than to json.loads.

It is faster, because it skips an entire intermediate representation. It produces better errors, because syntax and validation failures arrive through one channel with positions attached. It is more faithful, because decimals keep the digits as written. And it is shorter to write.

Very few changes improve four things at once for one fewer line of code.

Summary

model_validate_json for JSON text or bytes; model_validate for Python objects. One pass in Rust rather than two with garbage in between. Malformed JSON becomes a ValidationError with a position, so one handler covers syntax and validation alike. And decimals keep the precision that a trip through Python floats would have destroyed.

model_dump_json on the way out, TypeAdapter for shapes that are not models, and build adapters once rather than per call.

Where this fits

This is a small change with an unusually good ratio, and it applies in more places than request handling.

Reading a config file. Consuming a queue message. Validating another service's response. Loading a test fixture. Anywhere JSON text or bytes are in hand and a model is about to be built from them.

In each of those the two-step habit is common, and in each of them the one-step form is faster, produces better errors and keeps more precision. It is worth grepping for json.loads once and seeing how many of them are immediately followed by a validation call.

Mistakes people make

Parsing first out of habit. json.loads followed immediately by model_validate is the shape to grep for. Every instance of it is slower, produces worse errors and loses decimal precision compared with handing the text over directly.

Catching two exception types. Code with an except JSONDecodeError beside an except ValidationError is code that parsed separately. Validating the text directly collapses both into one channel and one handler.

Decoding bytes unnecessarily. An HTTP body is bytes and both methods accept bytes. Decoding to str first adds a step and a chance to assume the wrong encoding.

Expecting streaming. There is none. The whole document is parsed before validation completes, so a very large file is entirely in memory. Streaming needs a different tool producing records, each validated individually or in batches.

Assuming the fast path applies to YAML or TOML. Only JSON has a parser inside the core. Other formats go through their own libraries and produce Python objects, which then take the ordinary route — including losing decimal exactness on the way.

Using it when you already have a dict. There is nothing to parse. The rule is simply which you are holding: text or bytes go to model_validate_json, Python objects go to model_validate.

One line, four improvements

Speed, because an entire intermediate representation is skipped. Errors, because syntax and validation failures arrive through one channel with positions attached. Fidelity, because decimals keep the digits as written. Strictness, because the parser knows the input was JSON and applies the right rules.

All of it from handing Pydantic the text instead of parsing it first — which is also less code than the alternative.

Why the habit persists

The two-step form is not the result of anyone deciding it was better. It is what you write when you learn json before you learn Pydantic, which is the order nearly everybody learns them in.

json.loads is the obvious way to turn text into data, and once the data exists, validating it is the obvious next step. Both halves are reasonable and the combination is worse than either author intended.

That is worth naming because it explains why the pattern is everywhere, including in a lot of documentation and answers online, and why changing it is a matter of noticing rather than of understanding.

The check takes a minute: search a codebase for json.loads and look at the line after each one. Wherever it is a validation call, the two lines collapse into one that is faster, more precise about numbers, and produces errors that say where in the document the problem was.

There is rarely a reason to keep the two-step version once seen — unless something genuinely needs the raw structure in between, which is a real case and a small one.

Check yourself

0 of 4

Answer without scrolling back up.

  1. What does `model_validate_json` do with malformed JSON?

  2. Why is `model_validate_json` more faithful for a `Decimal` field?

  3. You already have a dict from a database driver. Which method?

  4. Where should a `TypeAdapter` be constructed?

Cheat sheet

Parsing JSON

It is not merely shorter. It is faster, produces better errors, and is more faithful with numbers. Three separate reasons, and they compound.

PYDANTIC · vizlearn.in/pydantic/parsing_json.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.