Exactly which values Pydantic will convert for you, and which it refuses. The single biggest source of surprise in the library.
Overview
Why it converts at all
The first time someone sees minutes="9" become 9 they usually ask whether that is safe. It is a fair question, and the answer is in where models get used.
Data arriving from outside a program is nearly always text. A query string is text. An HTML form submission is text. A CSV is text. An environment variable is text. If a model refused everything that was not already the right Python type, the code in front of every model would be a pile of int(...) calls wrapped in try, which is exactly the code the library exists to remove.
So the default — lax mode — is to accept any value with one unambiguous reading, and refuse the rest. The rules are worth knowing precisely, because "unambiguous" turns out to be a stricter test than most people expect.
Worth knowing
The default is called lax mode. It converts anything with a single unambiguous reading and refuses everything else.
9.0 -> 9 works but 9.5 -> 9 does not. Pydantic converts only when nothing is lost; it will not round for you.
int -> str does not happen. String is the one common field type that will not accept a number, because doing so hides real mistakes.
bool reads words: true/yes/on/1 and false/no/off/0. Anything else raises — unlike Python's bool(), where "false" is truthy.
In a Union, smart mode prefers an exact type match before it tries converting. Where the outcome matters, use a discriminated union rather than relying on order.
Strict mode is available per model (ConfigDict(strict=True)) or per field, so you can be lax at the boundary and strict inside.
Types and Coercion: What Pydantic Will and Will Not Convert
The rules behind the library's most surprising behaviour, one value at a time.
What becomes an int
Strings that read as whole numbers convert. Floats convert only when nothing is lost. Everything else raises — guessing would be worse than failing.
example_01.pyPydantic
Output
What becomes a float, and what becomes a str
Ints widen to floats without complaint. Strings, however, are strict in one surprising direction: a number is not silently turned into text.
example_02.pyPydantic
Output
Booleans are the trap
bool accepts a specific list of words and numbers. It is more generous than bool() and stricter at the same time — and the difference bites when parsing query strings.
example_03.pyPydantic
Output
Order matters inside a Union
A union tries its members left to right in smart mode. That is usually what you want, but it means the order you write can change the type you get.
example_04.pyPydantic
Output
Turning coercion off
When data should already be the right type — deep inside a system rather than at its edge — strict mode makes conversion an error instead of a convenience.
example_05.pyPydantic
Output
A realistic mixed payload
Everything so far, applied to the kind of dictionary a form or query string actually produces: all strings, several types wanted.
example_06.pyPydantic
Output
Into int
A string converts if it reads as a whole number, surrounding whitespace included: "9" and " 9 " both give 9.
A float converts only if nothing is lost. 9.0 becomes 9. 9.5 raises. This is the rule people are most often surprised by, and it is the right one: rounding silently is how a total ends up being a penny out and nobody can find why. If you want rounding, ask for it explicitly.
True becomes 1, because bool is a subclass of int in Python and always has been.
Everything else refuses: "nine", None, a list, a dict. There is no reading of "nine" that is unambiguous without inventing a natural-language parser.
Into float
More permissive, because floats can represent more. Integers widen (9 gives 9.0). Strings that read as numbers convert, and scientific notation like "1e3" works. True gives 1.0.
Note that this is one place where information genuinely can be lost — a very large integer will not survive a trip through a float exactly — but that is a property of floating point rather than of Pydantic.
Into str, and the asymmetry
Here is the rule that catches people: a number does not become a string. A field annotated str given 9 raises.
That seems inconsistent until you think about which direction the mistake usually runs. Text arriving where a number was wanted is normal — it is what the wire looks like. A number arriving where text was wanted is usually a genuine mistake in the calling code, and converting it would bury that mistake. The asymmetry is doing useful work.
If you actually want numbers accepted as text, say so with a validator that runs before conversion. There is a module on those later.
Into bool
Booleans have their own table, and it is not Python's.
True comes from True, 1, 1.0, and the strings "1", "true", "True", "yes", "on", "t", "y". False comes from False, 0, 0.0, and the strings "0", "false", "no", "off", "f", "n". Anything else — "maybe", 2, "", None — raises.
Compare that with plain Python, where bool("false") is True because the string is non-empty. Pydantic reads the *meaning* of the word rather than the emptiness of the container, which is almost always what you wanted when the value came from a checkbox or a query parameter. It is also why ?published=false does the right thing without you writing a special case.
Unions and order
A Union[int, str] has to decide which member to try. Pydantic v2 uses smart mode: it first looks for a member the value already matches exactly, and only then tries conversion, left to right.
That resolves the common cases sensibly — an actual int stays an int, an unconvertible string stays a string — but it does not remove ambiguity entirely. A string like "9" can satisfy both members, and which one wins depends on the order you wrote.
Where the distinction matters, do not lean on order. Say what you mean: a discriminated union picks a member by an explicit tag field, and strict mode removes conversion from the question. Both have modules later in this track.
Turning it off
Coercion is right at the boundary and often wrong inside. Once data has been validated at the door, a value arriving as the wrong type deeper in the system is a bug in your own code, and quietly fixing it hides the bug.
Strict mode makes conversion an error:
class Strict(BaseModel):
model_config = ConfigDict(strict=True)
minutes: int
Strict(minutes="9") now raises; Strict(minutes=9) is fine. Strictness can also be set on a single field, which is the more common need — lax about most things, exact about the one that matters.
There is a module on strict mode in the next tier that covers when the trade is worth making.
Reading the type codes
Every refusal carries a stable type code, and they are worth recognising because they say precisely which rule fired:
int_parsing means a string did not read as an integer. int_from_float means a float had a fractional part. string_type means a non-string reached a str field. bool_parsing means a string was not in the boolean table. Matching on these is more reliable than matching on the message, which is prose and may be reworded.
Dates, times and the other standard types
Pydantic knows the common standard-library types, and the conversions are the ones you would want.
A datetime field accepts a datetime, an ISO 8601 string such as "2026-08-26T14:30:00", and a Unix timestamp as an integer or float. A date accepts "2026-08-26". A time accepts "14:30:00". A timedelta accepts a number of seconds or an ISO 8601 duration.
UUID accepts a UUID or its string form. Decimal accepts a string, an int or a float — and for money you want the string, because Decimal(0.1) inherits the float's inaccuracy while Decimal("0.1") does not. Path accepts a string. Enum accepts the member or its value.
The pattern is consistent: the type itself always works, and the obvious textual representation works. That is what makes models useful directly against JSON, where none of these types exist natively.
Timezones deserve a warning. A naive datetime string produces a naive datetime, and comparing one of those to an aware one raises. If your application is timezone-aware, say so in the type with AwareDatetime, and the model will reject naive input rather than letting it through to fail later.
What a collection will accept
Container types coerce their contents, item by item.
List[int] given ["1", "2", "3"] gives [1, 2, 3] — each element goes through the same rules described above. If one element fails, only that element fails, and the error's loc names its index.
There is a shape rule as well as a content rule. A List[int] accepts a list, and in lax mode also a tuple, a set or a generator, because all of those are sequences of items. It does not accept a bare string, even though a string is technically iterable. That exception is deliberate and it is a mercy: List[str] given "abc" producing ["a", "b", "c"] would be a memorably bad afternoon.
Dict[str, int] coerces keys and values independently. Set[int] deduplicates, which means a set field can quietly return fewer items than were sent — usually what you want, occasionally a surprise.
Tuple[int, str] is checked positionally and by length: exactly two items, first an int, second a string. Tuple[int, ...] means any number of ints.
None is not a wildcard
A common early mistake is expecting None to be accepted wherever a value is missing. It is not. None is a value like any other, and it is accepted only where the type allows it — which means Optional[X], or X | None.
A field annotated int given None raises, with the type int_type. This is right: "no value" and "the number zero" are different facts, and a library that quietly turned one into the other would be hiding information.
If you want missing to mean something specific, say it with a default: minutes: int = 0 accepts an absent field and gives you a zero, while still refusing an explicit None.
JSON mode is stricter than Python mode
There are two validation modes, and they differ in ways that occasionally matter.
model_validate takes Python objects. model_validate_json takes a JSON string and parses it in the Rust core.
The distinction shows up because JSON has fewer types than Python. A JSON document has no datetime, so a datetime field validated from JSON must accept a string — and it does. But in Python mode, some conversions that would be ambiguous in JSON are allowed because the input type already disambiguates them.
For everyday models the two behave the same and you can ignore the difference. It becomes relevant with custom serialisers and with Decimal, where the JSON parser can preserve a number's exact textual form in a way that a Python float has already lost. When precision matters, validating from JSON directly is not just faster — it is more faithful.
The rules on one page
Worth committing to memory, because most surprises are one of these:
To int: whole-number strings yes, whitespace ignored; floats only when exact; True gives 1; anything else raises.
To float: ints yes, numeric strings yes, scientific notation yes.
To str: other strings only. Numbers, booleans and None all raise.
To bool: a fixed vocabulary of words and 0/1; everything else raises, including 2.
To a container: the items are coerced individually; a string is never treated as a sequence of characters.
None: allowed only where the annotation says so.
Everywhere: conversion never loses information silently. Where it would, it raises instead.
That last line is the principle the whole table is generated from. If you remember one thing, remember that, and you can usually predict the rest.
Debugging a conversion you did not expect
When a field comes out as something surprising, there is a quick sequence that finds the cause almost every time.
Print the type, not the value.print(type(m.minutes).__name__, m.minutes) distinguishes 9 the integer from 9 the string, which print(m.minutes) does not.
Look at the error's input. If it raised, the report shows what actually arrived. It is frequently not what the caller believed they sent — "null" as a four-character string, a number wrapped in a list, an empty string where a missing field was intended.
Check for a Union. Unexpected types nearly always come from a union member being chosen that you did not have in mind. Union[int, str] will hand you a string sometimes and an integer other times, and both are correct behaviour for the annotation you wrote.
Try it in strict mode. Temporarily setting strict=True turns every silent conversion into an error that names the field. It is the fastest way to find out which conversions a model is actually performing, and you can turn it off again afterwards.
What to take away
Coercion is the feature people distrust first and rely on most. It exists because the boundary is made of text, and it is bounded by a single principle: convert when the reading is unambiguous and nothing is lost, refuse otherwise.
Once that principle is in your head, the individual rules stop needing to be memorised. 9.5 to an int loses information, so it raises. "9" to an int loses nothing, so it converts. 9 to a string loses nothing either, but that direction hides caller mistakes, so it is the one deliberate exception — and knowing it is an exception is easier than remembering it as an arbitrary rule.
The practical shape
Run the last editor above and look at what happened. A dictionary of four strings — exactly what a browser sends — became a model with a string, an int, a float and a bool, each usable in arithmetic without a single conversion call in sight.
That is the whole value proposition. You wrote the types once, in the annotations, and the messy part happened at the boundary where it belongs.
The next module deals with the fields that are not always there: optional values, defaults, and the difference between "missing" and "null" that trips up nearly everyone.
Check yourself
0 of 4
Answer without scrolling back up.
A field is `n: int`. What does Pydantic do with the float `9.5`?
Conversion happens only when nothing is lost. `9.0` becomes `9`, but rounding `9.5` would discard information, so it refuses rather than guessing.
A field is `s: str`. What happens with the integer `9`?
This is the asymmetry to remember. Text arriving where a number is wanted is normal; a number arriving where text is wanted is usually a real bug in the caller, so it is not hidden.
What does a `bool` field do with the string `"false"`?
Pydantic reads the meaning of the word, not the emptiness of the container. Plain Python's `bool("false")` is `True`, which is why query-string parsing needs this behaviour.
When is strict mode the right choice?
At the boundary, coercion removes conversion code you would otherwise write. Inside, a wrong type is a bug of your own, and silently fixing it hides the bug.
Cheat sheet
Types and Coercion
The first time someone sees minutes="9" become 9 they usually ask whether that is safe. It is a fair question, and the answer is in where models get used.
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.