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.
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.
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.
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.
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.
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.
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.