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
Pydantic does not demand that incoming data already has the right Python type. It will try to convert it first. A string "36" becomes the integer 36. The number 1 can become True. This behaviour is called coercion, and it exists because most data arrives as text.
Coercion is useful, and it is also the source of many surprises. Some conversions feel obvious and others feel strange. A float with a fractional part will not become an integer, but a float like 3.0 will. A string will not be built out of an integer unless you ask for it. Knowing where the lines sit saves you from bugs that only show up with real traffic.
This article goes through the common targets one at a time. You will see what Pydantic accepts for integers, floats, strings and booleans, and why strings behave differently from the others. It then covers unions, where the order of the types can change the result, and finishes with the switch that turns coercion off. By the end you should be able to predict what a given input becomes before you run it.
Keep the examples in mind as you read. Each one is short enough to try yourself.
The first time someone sees minutes="9" become 9 they often ask whether that is safe. It's a fair question. 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 setting var is text. If a model refused all that wasn't already the right Python type, the code in front of each model would be a pile of int(...) calls wrapped in try. This Is just the code the library exists to drop.
So the default — lax mode — is to accept any value with one unambiguous reading. Refuse the rest. The rules are worth knowing just. "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 action, 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
from pydantic import BaseModel, ValidationError
class M(BaseModel):
n: int
for value in ["9", 9.0, True, " 9 ", 9, "9.0", 9.5, "nine", None, [9]]:
try:
print("%-8r -> %r" % (value, M(n=value).n))
except ValidationError as e:
print("%-8r -> refused (%s)" % (value, e.errors()[0]["type"]))
# Note 9.0 converts but 9.5 does not: dropping .5 would lose information
# you might have needed, so Pydantic refuses rather than rounding.
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
from pydantic import BaseModel, ValidationError
class F(BaseModel):
x: float
class S(BaseModel):
s: str
print("-- into float --")
for value in [9, "9.5", "1e3", True, "abc"]:
try:
print("%-8r -> %r" % (value, F(x=value).x))
except ValidationError:
print("%-8r -> refused" % (value,))
print()
print("-- into str --")
for value in ["hello", 9, 9.5, True, None]:
try:
print("%-8r -> %r" % (value, S(s=value).s))
except ValidationError:
print("%-8r -> refused" % (value,))
# str is the asymmetry worth remembering: int -> str does NOT happen.
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
from pydantic import BaseModel, ValidationError
class B(BaseModel):
flag: bool
for value in ["true", "True", "yes", "on", "1", 1,
"false", "no", "off", "0", 0,
"maybe", 2, "", None]:
try:
print("%-8r -> %r" % (value, B(flag=value).flag))
except ValidationError:
print("%-8r -> refused" % (value,))
# Compare with plain Python, where every non-empty string is truthy:
print()
print('bool("false") in plain Python is', bool("false"))
print("Pydantic reads the *meaning* of the word, not its emptiness.")
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
from typing import Union
from pydantic import BaseModel
class Loose(BaseModel):
v: Union[int, str]
class Reversed(BaseModel):
v: Union[str, int]
for value in ["9", 9, "nine"]:
a = Loose(v=value).v
b = Reversed(v=value).v
print("%-6r -> int|str gives %-6r (%s) str|int gives %-6r (%s)"
% (value, a, type(a).__name__, b, type(b).__name__))
# If the distinction matters, do not rely on order - say what you mean
# with a discriminated union, or turn on strict mode.
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
from pydantic import BaseModel, ConfigDict, ValidationError
class Lax(BaseModel):
minutes: int
class Strict(BaseModel):
model_config = ConfigDict(strict=True)
minutes: int
print("lax :", Lax(minutes="9").minutes)
try:
Strict(minutes="9")
except ValidationError as e:
print("strict :", e.errors()[0]["msg"])
print("strict :", Strict(minutes=9).minutes, "(a real int is fine)")
# Strict can also be set per field - see the constraints module.
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
from pydantic import BaseModel
class ModuleForm(BaseModel):
title: str
minutes: int
rating: float
published: bool
# Exactly what a browser form submission looks like: strings throughout.
raw = {"title": "Bayes' Theorem", "minutes": "12",
"rating": "4.5", "published": "on"}
m = ModuleForm.model_validate(raw)
print("model :", m)
print()
for name, value in m.model_dump().items():
print(" %-10s %-8r %s" % (name, value, type(value).__name__))
print()
print("arithmetic works now:", m.minutes * 2, "|", round(m.rating + 0.5, 1))
Output
Into int
A string converts if it reads as a whole number, nearby whitespace included: "9" and " 9 " both give 9.
A float converts only if nothing is lost. 9.0 turns 9. **9.5 raises.** This is the rule people are most often surprised by. It's the right one. Rounding silently is how a total ends up being a penny out and no one can find why. If you want rounding, ask for it by name.
True turns 1, because bool is a subclass of int in Python and always has been.
all else refuses: "nine", None, a list, a dict. There's no reading of "nine" that is unambiguous without inventing a natural-language parser.
Into float
More permissive, because floats can represent more. ints widen (9 gives 9.0). Strings that read as numbers convert. Scientific form like "1e3" works. True gives 1.0.
Note that this is one place where data truly can be lost. A very large int won't live a trip through a float just. 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 doesn't become a string. A field annotated str given 9 raises.
That seems inconsistent until you think about which direction the mistake often runs. Text arriving where a number was wanted is normal. It's what the wire looks like. A number arriving where text was wanted is often a genuine mistake in the calling code. Converting it would bury that mistake. The skew is doing handy work.
If you really want numbers accepted as text, say so with a checker that runs before conversion. There's a module on those later.
Into bool
Booleans have their own table, and it isn't Python's.
True comes from True, 1, 1.0. 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. Here bool("false") is True because the string is non-empty. Pydantic reads the *meaning* of the word rather than the emptiness of the holder. This Is almost always what you wanted when the value came from a checkbox or a query param. It's 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 just. 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 doesn't drop ambiguity fully. A string like "9" can meet both members. Which one wins depends on the order you wrote.
Where the gap matters, don't lean on order. Say what you mean. A discriminated union picks a member by an clear tag field. Strict mode drops 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 checked at the door, a value arriving as the wrong type deeper in the system is a bug in your own code. 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. This Is the more common need. Lax about most things, exact about the one that matters.
There's a module on strict mode in the next tier that covers when the trade is worth making.
Reading the type codes
each refusal carries a stable type code. They're worth recognising because they say just which rule fired:
int_parsing means a string didn't read as an int. 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 wasn't in the boolean table. Matching on these is more solid than matching on the message. This 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'd want.
A datetime field accepts a datetime, an ISO 8601 string such as "2026-08-26T14:30:00". A Unix timestamp as an int or float. A date accepts "2026-08-26". A time accepts "14:30:00". A timedelta accepts some 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. Decimal(0.1) takes from the float's inaccuracy while Decimal("0.1") doesn't. Path accepts a string. Enum accepts the member or its value.
The pattern is steady: the type itself always works. The obvious textual form works. That is what makes models handy right against JSON. Here None of these types exist natively.
Timezones deserve a warning. A naive datetime string makes a naive datetime. Checking one of those to an aware one raises. If your use is timezone-aware, say so in the type with AwareDatetime. The model will reject naive input rather than letting it through to fail later.
What a collection will accept
holder types coerce their contents, item by item.
List[int] given ["1", "2", "3"] gives [1, 2, 3]. Each element goes through the same rules given above. If one element fails, only that element fails. The error's loc names its index.
There's a shape rule and a content rule. A List[int] accepts a list. In lax mode also a tuple, a set or a gen. All of those are sequences of items. It does not accept a bare string, though a string is strictly iterable. That exception is deliberate and it's a mercy: List[str] given "abc" making ["a", "b", "c"] would be a memorably bad afternoon.
Dict[str, int] coerces keys and values on its own. Set[int] deduplicates. This Means a set field can quietly return fewer items than were sent. Often what you want, at times a surprise.
Tuple[int, str] is checked positionally and by length. Just 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 isn't. None is a value like any other. It's 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 other facts. A library that quietly turned one into the other would be hiding data.
If you want missing to mean a thing exact, say it with a default: minutes: int = 0 accepts an absent field and gives you a zero, while still refusing an clear None.
JSON mode is stricter than Python mode
There are two check modes, and they differ in ways that at times matter.
model_validate takes Python objects. model_validate_json takes a JSON string and parses it in the Rust core.
The gap shows up because JSON has fewer types than Python. A JSON doc has no datetime. A datetime field checked 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 skip the gap. It turns relevant with custom serialisers and with Decimal. Here The JSON parser can preserve a number's exact textual form in a way that a Python float has already lost. When precision matters, checking from JSON right isn't just faster — it's 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 skipped. Floats only when exact. True gives 1. Anything else raises.
**To float:** ints yes, numeric strings yes, scientific form yes.
**To str:** other strings only. Numbers, booleans and None all raise.
**To bool:** a fixed vocab of words and 0/1. All else raises, including 2.
To a holder: the items are coerced on their own. A string is never treated as a sequence of characters.
**None:** allowed only where the annotation says so.
all over: conversion never loses data silently. Where it would, it raises instead.
That last line is the principle the whole table is made from. If you note one thing, note that. You can often predict the rest.
Debugging a conversion you did not expect
When a field comes out as a thing surprising, there's a quick sequence that finds the cause almost each time.
Print the type, not the value.print(type(m.minutes).__name__, m.minutes) sets apart 9 the int from 9 the string, which print(m.minutes) doesn't.
**Look at the error's input.** If it raised, the report shows what really arrived. It's often 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.** surprise types nearly always come from a union member being chosen that you didn't have in mind. Union[int, str] will hand you a string at times and an int other times. Both are correct action for the annotation you wrote.
Try it in strict mode. Temporarily setting strict=True turns each silent conversion into an error that names the field. It's the fastest way to find out which conversions a model is really performing. You can turn it off again later.
What to take away
Coercion is the feature people distrust first and lean on most. It exists because the boundary is made of text. It's bounded by a single principle. Convert when the reading is unambiguous and nothing is lost, refuse else.
Once that principle is in your head, the single rules stop needing to be stored. 9.5 to an int loses data. 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's the one deliberate exception. And knowing it's an exception is easier than remembering it as an any rule.
Questions people ask about Types and Coercion
Why does "36" become the integer 36? Lax mode converts compatible values. A numeric string is accepted for an int, and a float with no fractional part is accepted too.
Why is "abc" rejected for an int? It cannot be parsed as a number, so you get an int_parsing error. Pydantic only coerces when the conversion is unambiguous.
Which strings become True for a bool?"true", "yes", "on", "1" and "t", "y", in any case. The false set is the matching negatives. Other strings fail.
Is a float accepted for an int field? Only if it has no fractional part. 3.0 becomes 3, but 3.5 raises an int_from_float error.
Does an int become a str? No. Pydantic v2 does not turn numbers into strings by default. Set coerce_numbers_to_str=True if you want that.
How do I stop coercion? Use strict mode, either on one field with Field(strict=True) or on the whole model with ConfigDict(strict=True).
The practical shape
Run the last editor above and look at what happened. A dict of four strings. Just what a browser sends. Became a model with a string, an int, a float and a bool, each usable in math without a single conversion call in sight.
That is the whole value proposition. You wrote the types once, in the annotations. The messy part happened at the boundary where it belongs.
The next module deals with the fields that aren't always there. Optional values, defaults. The gap between "missing" and "null" that trips up nearly all.
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
See exactly what Pydantic will and will not convert: the coercion rules for strings, ints, floats and booleans, one example at a time.
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.