Parsing JSON

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

Overview

The habit worth changing

Most people parse JSON with the standard library and then pass the result to a model. That is two steps, and it works. It is also a habit worth changing, because Pydantic can do both steps in one call.

The method is model_validate_json. It reads the JSON string or bytes directly and validates as it goes. That brings three gains. It is faster, because the parsing happens in Rust. The error messages are better, because they refer to the original text. And it keeps number fidelity, so large integers and precise decimals do not get mangled on the way through Python.

This article explains the habit and why you might want to drop it. It covers speed, error quality and fidelity with numbers, one after another. It then looks at the other direction, turning a model back into JSON text. It also notes that bytes work as input, which matters when you read from a network socket or a file. The aim is to make the single-step version your default for JSON input, with a clear idea of why.

Almost every service parses JSON, so even a small gain here applies to nearly all your traffic. The change is tiny. It takes one method call in place of two. Yet it affects speed, error clarity and the accuracy of your numbers all at once. The article makes the case with concrete examples rather than claims, so you can see each benefit for yourself. For example, you will see the same bad input fail in both styles, and compare the messages. One points at the exact spot in the text. The other can only tell you that something is wrong. You will also learn the one situation where the two-step habit is still the better choice. After reading, you will know when to use each approach and why, instead of copying whichever one you saw last.

Real payloads rarely look like textbook examples. They arrive from browsers, mobile apps and other servers, each with its own habits. Some contain huge integers. Some contain numbers with many digits after the point. A parser that quietly rounds them can cause trouble that is very hard to trace. Seeing how each approach treats such values helps you choose with confidence, and the examples here use exactly those cases.

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 isn't merely shorter. It's faster, makes better errors. Is more faithful with numbers. Three split 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.

each mid 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 makes the final values right, skipping the mid form fully.

The saving grows with payload size. For a small request body it's unimportant. For a large doc, or a high-throughput endpoint, it's a real gap for a change that makes the code shorter.

Error quality

This is the arg that matters most day to day.

With json.loads, malformed JSON raises json.JSONDecodeError. A other exception, from a other library, that knows nothing about your model. Your handler needs two except clauses making two shapes of error response. The JSON one has no field data because there are no fields yet.

model_validate_json folds it in. Malformed JSON turns a ValidationError with the type json_invalid, arriving through just 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 spot in the doc. For a missing comma four hundred lines into a config file, being told the line and column is the gap 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. 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 make it from the exact text. 0.1 in the doc turns Decimal("0.1") rather than the float's guess.

For anything money, that isn't a micro-tuning. It's the gap 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 right recovers more of it than going via Python.

The other direction

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

A round trip is exact for each type Pydantic knows. A date turns an ISO string and parses back to the same date. A Decimal turns a string and returns as the same Decimal. A UUID lives. This makes JSON a fair format for caching checked objects. Makes equality assertions in tests trustworthy.

Bytes work too

Both methods accept bytes and str. This Is what an HTTP body really is.

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

Non-model shapes

Not each payload is an object. TypeAdapter gives 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 gain, same error handling, same fidelity. And dump_json in the other direction, which returns bytes.

Build the adapter once at module level. Making one compiles a schema. Doing that inside a loop is a real and easily-missed speed mistake.

When to keep the two steps

There are legitimate reasons to parse apart.

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

The input isn't JSON. YAML, TOML and msgpack all give Python objects. model_validate is the right entry point for them.

You already have a dict from a DB driver, another library, or your own code. There's nothing to parse.

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

In practice with a framework

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

Where it matters in use code is all over else. Reading a config file, consuming a queue message, calling another service and checking 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 checking from JSON still accepts the string forms JSON has no option to. A date as text, say.

That action depends on Pydantic knowing the input was JSON. This 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. In strict mode refuses it.

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

Large payloads

For very large docs, two things are worth knowing.

Parsing is a single pass and memory-bounded by the result rather than by mid objects. model_validate_json uses usefully less memory than the two-step form on a big doc.

There's no streaming. The whole doc is parsed before check ends. A hundred-megabyte file is a hundred megabytes in memory. If that matters, the answer is at a other level. A streaming JSON reader making records, each checked on their own 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 makes a ValidationError naming the spot rather than a JSONDecodeError from elsewhere.

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

Other formats

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

YAML, TOML and msgpack all go through their own libraries. This Give Python objects. This Then go to model_validate. That is the correct shape for those formats and there's nothing to optimise. But it does mean a YAML config gets none of the fidelity gain for decimals. 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're holding JSON text or bytes, hand it to Pydantic rather than to json.loads.

It's faster, because it skips an entire mid form. It makes better errors, because syntax and check failures arrive through one channel with spots attached. It's more faithful, because decimals keep the digits as written. And it's shorter to write.

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

Parsing JSON: 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 turns a ValidationError with a spot. One handler covers syntax and check 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 aren't models. Build adapters once rather than per call.

Where this fits

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

Reading a config file. Consuming a queue message. checking 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. In each of them the one-step form is faster, makes better errors and keeps more precision. It's worth grepping for json.loads once and seeing how many of them are at once followed by a check call.

Mistakes people make with Parsing JSON

Parsing first out of habit. json.loads followed at once by model_validate is the shape to grep for. each instance of it's slower, makes worse errors and loses decimal precision checked with handing the text over right.

Catching two exception types. Code with an except JSONDecodeError beside an except ValidationError is code that parsed apart. checking the text right merges both into one channel and one handler.

Decoding bytes needlessly. 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's none. The whole doc is parsed before check ends. A very large file is fully in memory. Streaming needs a other tool making records, each checked on their own or in batches.

given the fast path applies to YAML or TOML. Only JSON has a parser inside the core. Other formats go through their own libraries and give Python objects. This Then take the plain route. Including losing decimal exactness on the way.

Using it when you already have a dict. There's nothing to parse. The rule is simply which you're holding. Text or bytes go to model_validate_json, Python objects go to model_validate.

One line, four improvements

Speed, because an entire mid form is skipped. Errors, because syntax and check failures arrive through one channel with spots 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 option.

Questions people ask about Parsing JSON

Should I use json.loads then model_validate, or model_validate_json? Prefer model_validate_json. It parses and validates in one pass in Rust, which is faster and gives better error locations.

Does model_validate_json accept bytes? Yes, both str and bytes work, so you can pass a request body directly without decoding.

What happens when the JSON is malformed? You get a ValidationError with type json_invalid and the position of the problem, not a JSONDecodeError.

How do I parse a top-level JSON array? Use TypeAdapter(list[Model]).validate_json(data). A model cannot represent a bare array.

Do the coercion rules differ for JSON input? Slightly. Tuples and sets are read from arrays and dates from ISO strings, but a string like "1" for an int field is still coerced in lax mode unless you turn on strict mode.

Can it handle large integers? Yes. Python ints are unbounded, and Pydantic parses large JSON integers exactly instead of rounding to a float.

Why the habit persists

The two-step form isn't the result of anyone deciding it was better. It's what you write when you learn json before you learn Pydantic. This Is the order nearly everybody learns them in.

json.loads is the obvious way to turn text into data. Once the data exists, checking it's the obvious next step. Both halves are fair and the mix is worse than either author intended.

That is worth naming because it shows why the pattern is all over, including in a lot of docs and answers online. Why changing it's a matter of noticing rather than of understanding.

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

There's rarely a reason to keep the two-step version once seen. Unless a thing truly needs the raw structure in between. This 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

Parse JSON directly into a model with model_validate_json and see why it is faster and stricter than json.loads plus model_validate.

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.