Strict vs Lax Mode

Coercion is right at the edge and wrong in the middle. How to be lax where text arrives and exact everywhere else.

Overview

Two different jobs

By default Pydantic runs in lax mode. It converts values when it can, so "5" becomes 5 and 1 becomes True. That is friendly, and for data that arrives as text it is usually what you want. It can also hide bugs, because a wrong type gets quietly fixed instead of reported.

Strict mode does the opposite. It accepts only values that already have the right type. A string where an integer belongs is an error, even if the string looks like a number. The two modes do different jobs, and choosing between them is a design decision about how much you trust your input.

This article explains the two jobs and how to switch strict mode on. It shows that you can set it for a whole model, but that a per-field setting is usually the better answer. It also covers what strict mode still allows, since it is less rigid than people expect. The last section looks at strict mode and JSON, where the rules differ from Python input. After reading it you will know when a strict field is worth the friction.

all in this tier has assumed lax mode. This Is the default: convert anything with an unambiguous reading, refuse the rest.

That is just right at a boundary. A query string is text. A form post is text. A CSV is text. If check refused all that wasn't already the right Python type, the code in front of each model would be a pile of int() calls in try blocks. This Is the code the library exists to delete.

It's just wrong three layers in. If a function inside your own system passes a string where an int was expected, that is a bug you wrote. Converting it silently means the bug lives. The symptom appears one spot else. Often as a number that is subtly wrong rather than an error that names the cause.

Same action, opposite value, based on which side of the boundary you're standing on. Which is why the setting exists.

Worth knowing

Lax is the default and it exists because the boundary is made of text. Query strings, form posts and CSVs have no integers in them.
Strict requires the value to already be the annotated type. It is set per model with ConfigDict(strict=True) or per field with Field(strict=True).
Per-field is usually what you want. Identifiers and money are the classic cases: a silent conversion there hides a real upstream mistake.
Strict still permits lossless widening — int to float — and bool remains a subclass of int.
In strict mode, JSON input still accepts the string forms JSON forces, such as dates. Strictness is about avoiding unnecessary conversion, not about refusing the format.
StrictInt, StrictStr, StrictBool and StrictFloat are ready-made types for the same thing.

Strict vs Lax Mode: Where Coercion Helps and Where It Hides Bugs

The default is right at the edge of your system and wrong in the middle. Choosing per field is the answer.

The same model, two answers

Lax converts anything unambiguous. Strict requires the value to already be the annotated type.

example_01.pyPydantic
Output

Strict on one field only

The usual need is narrower than a whole model: lax about most of a payload, exact about the one field where a quiet conversion would be dangerous.

example_02.pyPydantic
Output

Strict as a reusable type

Annotated turns strictness into a named type, so the rule lives in one place and reads well at every use.

example_03.pyPydantic
Output

What strict still allows

Strict is not “no conversion at all”. Widening that cannot lose information still happens, and a bool is still an int in Python.

example_04.pyPydantic
Output

Strict doesn't mean "no conversion whatsoever", and the exceptions are principled.

An int is still accepted for a float field. Widening an int to a float loses nothing and Python treats the two as by number matching throughout.

A bool is still accepted for an int field. bool truly is a subclass of int in Python. That one at times surprises people. If it matters, a checker is the way to exclude it.

What strict drops is parsing. Turning text into a thing else. "3.0" to a float is parsing, and strict refuses it.

JSON is strict-aware

Validating from JSON in strict mode still accepts the forms JSON has no choice about — a date has to arrive as text because JSON has no dates.

example_05.pyPydantic
Output

The pattern worth copying

Two models: a lax one at the boundary that accepts the wire format, and a strict one inside where a wrong type means a bug of your own.

example_06.pyPydantic
Output

Turning it on

Per model:

class Config(BaseModel):
    model_config = ConfigDict(strict=True)

Per field:

user_id: int = Field(strict=True)

Or as a reusable type. This Is often the nicest:

UserId = Annotated[int, Field(strict=True)]

Pydantic also ships StrictInt, StrictStr, StrictBool and StrictFloat. This Are the same thing pre-named.

Per field is the usual answer

Whole-model strictness sounds tidy and is often impractical. A real payload mixes fields that arrive as text with fields that don't.

The narrower need is much more common: be lax about most of a model and exact about the one or two fields where a quiet conversion would be dangerous.

Identifiers are the classic case. A user_id arriving as "42" rather than 42 often means a thing upstream lost a type. A JSON serialiser configured oddly, a value that went through a URL and was never converted back. Accepting it papers over that. Refusing it tells you where the problem is while it's still cheap to find.

Money is the other. A Decimal field that accepts a float will accept an already-inexact value and validate it happily. This Is the worst possible outcome for a currency amount.

Booleans are worth considering too. bool is generous in lax mode. "yes", "on", "t" all work. And that is truly handy for a checkbox and truly alarming for a flag that controls whether money moves.

Strict and JSON

There's a subtlety worth understanding, because it looks like an inconsistency and isn't.

In strict mode, checking from a JSON string still accepts the textual forms JSON has no option to. A date field checked from JSON accepts "2026-08-26". JSON has no date type and there's no stricter form on hand.

checked from Python, the same strict model refuses that string. In Python a real date object *was* on hand and a string means somebody skipped a step.

The principle is steady once stated: strict mode refuses *not needed* conversion. When the format offers no option, accepting the only on hand form isn't laxity.

This is one more reason model_validate_json is worth preferring over json.loads plus model_validate. It knows the input was JSON and applies the right rules. Going via Python objects loses that context.

The two-model pattern

The clean way to express all of this is a lax model at the boundary and a strict one inside:

class ModuleIn(BaseModel):          # boundary: text arrives, convert it
    title: str
    minutes: int

class Module(BaseModel):            # interior: types are already right
    model_config = ConfigDict(strict=True, frozen=True)
    title: str
    minutes: int

The boundary model absorbs the messiness of the wire. The interior model is a statement that all past this point has been checked. A mistake in your own code will be caught rather than converted.

Adding frozen=True to the interior model is a natural companion. Checked data that only gets read has no reason to be mutable. Freezing it drops another class of question.

Whether this is worth two classes depends on the system. For a small use it's over-engineering. One lax model is fine. For a large one with a few layers and a few teams, the strict interior model is a contract that catches real mistakes.

A middle path

If two models feels like too much, there's a lighter version that gets most of the gain. Turn on validate_assignment and make the key fields strict.

class Module(BaseModel):
    model_config = ConfigDict(validate_assignment=True)
    user_id: Annotated[int, Field(strict=True)]
    minutes: int

Now the payload can arrive as text and be converted. The identifier must be right. Later sets are checked rather than trusted. One class, and the two most common bug sources are closed.

When to reach for strict

Do use it on identifiers, money, and any flag with results.

Do use it for interior models in a layered system. Here Wrong types show your own bugs.

Do use it in tests. Here You want to assert on just what a function made without coercion smoothing it over.

Don't use it on a public API's request model. Your clients send JSON, JSON sends text. Refusing "12" for a duration will make support tickets rather than better data.

Don't reach for it as a general "safer" setting. Lax mode isn't sloppy. It's the correct action for the job it was designed for. Strictness applied indiscriminately just moves work back into your callers.

Strictness in tests

There's a use for strict mode that has nothing to do with production. It's one of the most valuable.

In a test, coercion can hide the thing you're asserting. A test that checks a function returned minutes=12 will pass if the function returned "12" and the model converted it. So the test doesn't really verify the action it claims to.

checking the result with a strict model closes that gap. The assertion turns about the real type. A function that starts returning strings fails the test rather than quietly leaning on coercion downstream.

The same applies to fixtures. A fixture built with minutes="12" isn't exercising the same code path as production data that arrives as an int, and strictness makes that visible.

What strict does not protect you from

Worth being clear about the limits. "strict" sounds like a general safety setting and isn't.

It doesn't check ranges. StrictInt accepts -999999 happily. That is what Field(gt=0) is for.

It doesn't check meaning. A strict str accepts an empty string, a string of spaces. A string holding a script tag.

It doesn't make a model safe to expose. Strictness is about types, not about authorisation, sanitisation or business rules.

And it doesn't drop the need to think about the boundary. A strict model at the edge of a public API doesn't make the API safer. It makes it harder to call right. This Pushes the conversion into your consumers' code where you can't see it.

Reading the errors

Strict failures have their own error types, and recognising them saves a moment's confusion.

Where a lax model would report int_parsing for "twelve", a strict model reports int_type for "12". The message is about the *type* being wrong rather than the value being unparseable.

That gap is truly handy in a log. int_parsing means somebody sent nonsense. int_type on a strict field means somebody sent a well-formed value in the wrong form. This Is often a wiring problem in a caller rather than bad input from a user. The two deserve other responses.

A decision you can apply mechanically

For each field, ask where its value comes from.

From a human, a form, a URL or a CSV. Lax. It's text, it will always be text. Refusing it moves work to the caller with no gain.

From another service's JSON — lax for anything JSON has no type for, strict for identifiers. A partner sending "user_id": "42" is worth knowing about.

From your own code, past the boundary. Strict. A wrong type here is your bug and you want it loud.

From a test — strict. So The assertion means what it says.

Applied field by field, that makes models that are permissive just where permissiveness helps and exact all over else. Which is the whole point. Isn't a thing a single global setting can express.

The principle underneath

Both modes exist because check answers a other question in other places.

At the boundary the question is "can I make sense of this?". Being generous is correct. The sender had no better option than text.

Inside the question is "is this what I think it's?". Being generous is wrong. The sender is you and a mismatch means a thing is broken.

Getting this right is mostly a matter of noticing which question you're asking. The setting is small. The habit of asking is the thing worth taking away.

One more reason it matters

Coercion is the feature people are most suspicious of when they meet Pydantic and most dependent on within a week. That reversal is worth understanding. It's the same reversal that makes the strict/lax choice feel hard.

The suspicion is fair: a library that silently changes your data sounds like a library that will eventually change it wrongly. The dependence is also fair: the boundary really is made of text. A thing has to do the converting.

What resolves it's that Pydantic's conversion is bounded by a rule you can state in one sentence. Convert when the reading is unambiguous and nothing is lost. And that you can switch it off, per field, wherever that rule isn't the one you want.

Very few libraries give you both the sensible default and the precise replace. Knowing that the replace exists. Where to apply it, is what turns coercion from a thing you handle into a thing you've decided about.

Where to start if you are unsure

If this is a new code, the pragmatic default is: lax all over, plus strict=True on identifiers and money.

That mix takes ten seconds to apply, keeps each request model easy to call. Closes the two cases where a silent conversion most often shows a real problem upstream. It's a better starting point than either extreme. You can tighten exact fields later as you learn where your data really goes wrong.

The one thing worth doing on purpose rather than by default is choosing, once, for each new field. Strictness applied by habit is no better than laxity applied by habit.

Where tier two leaves you

You can now show data with real shape. Models inside models, sets with real error paths, unions that say which branch they're, closed sets of values. The standard-library types that JSON can't carry. And you can decide, per field, how much conversion you're willing to accept.

What you can't yet do is express a rule that no annotation can hold. A value that must be checked against another value, a field found from the rest, a scaling applied before checking. That is the next tier, and it starts with checkers.

Questions people ask about Strict vs Lax Mode

What does lax mode do? It converts compatible input, so "42" becomes 42. It is the default.

What changes in strict mode? No type conversion happens. A string where an int is expected is rejected with an int_type error.

How do I turn strict on for one field? Use Field(strict=True) or a type like StrictInt. This is usually better than switching the whole model.

Can I enable strict for a whole model? Yes, with model_config = ConfigDict(strict=True). Individual calls can also pass strict=True to model_validate.

Does strict mode treat JSON differently? Yes. JSON has no distinct date type, so strict JSON mode still accepts ISO strings for dates, while strict Python mode wants a real date.

Does strict stop True being accepted as an int? Yes. In strict mode a bool is not accepted for an int field, even though bool subclasses int in Python.

Strict vs Lax Mode: summary

Lax mode converts anything unambiguous and is correct at the boundary. The boundary is text. Strict mode needs the annotated type and is correct inside, because a mismatch there's your own bug.

Set it per field rather than per model often. Strict still permits lossless widening. Still accepts the string forms JSON has no option to. Identifiers, money and consequential flags are the fields where it earns its place first.

Check yourself

0 of 4

Answer without scrolling back up.

  1. Why is lax mode the default?

  2. Does strict mode refuse an `int` for a `float` field?

  3. A strict model validates a `date` field from a JSON string. What happens?

  4. Which field is the classic candidate for `strict=True` in an otherwise lax model?

Cheat sheet

Strict vs Lax Mode

Choose between strict and lax validation per field or per model, and learn where coercion helps at the edge and hides bugs inside.

PYDANTIC · vizlearn.in/pydantic/strict_vs_lax_mode.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.