Strict Mode & Type Coercion
Pydantic’s coercion is a feature and a trap. It is a feature because environment variables are always strings and you want "8080" to become an int. It is a trap because the same leniency can turn "0" into a truthy value somewhere you needed a strict boundary. This page makes coercion an explicit, per-field decision.
Coercion control is the layer between settings fundamentals and custom validators in the type-safe validation section.
The mental model to hold is that coercion is pydantic answering the question “the declared type is int, but I received the string "8080" — should I convert?” For settings the answer is almost always yes, because the source is the environment and the environment only ever hands you strings; a settings model that refused to convert would reject every value it was built to read. But “convert if plausible” is a spectrum, and the whole skill is knowing where on that spectrum each field should sit. A port coerced from "8080" is exactly right. A replica count coerced from a float 2.0 that arrived in a JSON payload might be hiding a producer bug you would rather catch. The same leniency is a convenience in the first case and a blind spot in the second, and pydantic v2 gives you a per-field dial — strict — to choose. This page is about turning that dial deliberately rather than accepting the default everywhere or, worse, flipping the whole model strict and breaking every environment read. The default itself is well chosen — lenient coercion is what makes a settings model usable against the environment at all — so most of the time you leave it alone and reach for strictness only at the specific fields where a coerced value would be a bug rather than a convenience.
One clarification prevents most confusion up front: the notorious “"False" is truthy” bug is not a pydantic behaviour. It comes from calling Python’s built-in bool() on a non-empty string, where every non-empty string — including "false" — is True. Pydantic does the opposite of that footgun: it understands "false", "0", "no", and "off" and parses them to False. So the fix for the truthy-string bug is not to configure pydantic differently; it is to stop bypassing the model and calling bool() on the raw string yourself. Route the value through a typed bool field and the bug cannot occur.
Secure implementation
# config/strict_settings.py
from typing import Annotated
from pydantic import Field, Strict, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class StrictSettings(BaseSettings):
model_config = SettingsConfigDict(extra="forbid")
# Lenient: env var "8080" is coerced to int 8080.
port: int = Field(default=8080, ge=1, le=65535)
# Lenient bool: "true"/"false"/"1"/"0"/"yes"/"no" parsed correctly by pydantic.
debug: bool = False
# Strict: must already be the right type — useful when fed from JSON, not env.
replicas: Annotated[int, Strict()] = 3
secret_key: SecretStr
Most env-sourced fields should stay lenient; reserve Strict() for values that arrive already-typed (from a parsed JSON document) where a silent string-to-number coercion would mask a bug.
There are three levels at which you can control strictness, and choosing the right one is most of the craft. The finest is per field, with Annotated[int, Strict()] or Field(strict=True) — this is the tool you want almost always, because it lets a replicas field be strict while port stays lenient on the same model. The coarsest is model-wide, with SettingsConfigDict(strict=True), which makes every field strict; this is rarely right for a BaseSettings model precisely because its inputs are environment strings, and a model-wide strict setting would reject "8080" for an int port — the exact kind of value the environment is designed to deliver. Between them, an Annotated type alias (StrictInt = Annotated[int, Strict()]) lets you name a strict type once and reuse it across fields and models, which keeps the intent visible and the behaviour consistent.
The deciding question for any field is where its value comes from. If it comes from the environment — the usual case for settings — keep it lenient, because the input is definitionally a string and coercion is doing necessary work. If it comes from an already-structured source where the type is meaningful — a JSON document from an upstream service, a value passed programmatically in a test, a payload where a string-where-you-expected-a-number signals a real defect — make it strict, so the mismatch surfaces as a validation error instead of being silently smoothed over. Strictness is not about being “safer” in the abstract; it is about matching the field’s tolerance to the trustworthiness of its source.
Annotated is the mechanism that keeps this readable as a model grows. Because Strict(), Field() constraints, and validators all compose inside Annotated, you can express a field’s full rule in its type: Annotated[int, Strict(), Field(ge=1, le=64)] says “a real integer, between 1 and 64” in one place, and naming that alias (Workers = Annotated[int, Field(ge=1, le=64)]) lets every field and every model that uses it inherit the same rule. This matters for coercion specifically because it moves the lenient-or-strict decision out of scattered Field(strict=True) calls and into named types whose intent is visible at a glance. A reviewer reading replicas: StrictInt knows immediately that this field will not coerce, without hunting for a strict=True buried in a Field.
Optional fields and the empty string
A field typed Optional[str] (or str | None) introduces a coercion question the scalar cases do not: what should an empty environment value mean? Setting PROXY_URL= (present but empty) gives pydantic the empty string "", which is a valid str and therefore not None — so an Optional[str] = None field set to an empty variable holds "", not the default. If your code treats “unset” and “empty” the same, this bites: if settings.proxy_url is None is False for an empty variable even though semantically nothing was configured. The clean fix is a @field_validator(mode="before") that maps "" to None, making “empty means unset” an explicit rule rather than an assumption. Remember too, from the fundamentals page, that in pydantic v2 an Optional annotation does not imply a default — write x: Optional[str] = None explicitly, or the field is required and merely nullable, which is a different thing.
Union fields and coercion order
When a field is a Union — say int | str — coercion order determines which member wins, and the result can surprise you. In its default “smart” mode, pydantic tries to find the best match rather than the first, but with ambiguous input the outcome depends on the members’ order and their coercibility, so "8080" against int | str might land as the integer 8080 or the string "8080" depending on how the union is declared. The lesson is to avoid unions for settings fields wherever a single concrete type will do — most configuration values have one real type — and where a union is genuinely needed, make the intent unambiguous with Strict() on the members or a @field_validator that decides the branch explicitly. A settings field whose type is a union of coercible types is a small puzzle you are leaving for your future self; prefer the boring single type wherever the domain allows it.
Configuration reference
| Input | Target | Lenient result | Strict result |
|---|---|---|---|
"8080" |
int |
8080 |
ValidationError |
"true" |
bool |
True |
ValidationError |
"false" |
bool |
False |
ValidationError |
1 |
bool |
True |
ValidationError |
"1.5" |
int |
ValidationError |
ValidationError |
Two rows in that table repay a closer look. The last one — "1.5" rejected as an int even under lenient coercion — shows that “lenient” does not mean “anything goes”. Pydantic will convert a string that represents an integer ("8080"), but it will not silently truncate a fractional value just to make it fit the declared type; "1.5" is not an integer, so it is rejected in both modes. That is a deliberate safety line: coercion fills the gap between the string transport and the declared type, but it does not invent a conversion that loses information. If you genuinely want "1.5" to become 1, you must ask for it explicitly with a validator that converts, so the truncation is visible in the code rather than hidden in the type system.
The bool rows show the parsing set worth memorising: "true", "1", "yes", "on" (and their uppercase forms) become True; "false", "0", "no", "off" become False; anything else is a validation error. This is a closed, predictable set, which is exactly why a typed bool field is safer than a hand-rolled check — the field either gets a value it recognises or it rejects the input loudly, with no silent misinterpretation anywhere in between. Note too that under strict mode a bool field will reject the string "true" entirely, accepting only a real boolean, which is why bool fields sourced from the environment must stay lenient.
It is worth internalising why the boolean case is where lenient coercion earns the most trust. A boolean is the field type most likely to be hand-parsed badly, because it looks simple: developers reach for os.getenv("FLAG") == "true" and immediately have a case-sensitivity bug (FLAG=True is now false) or reach for bool(os.getenv("FLAG")) and have the truthiness bug (FLAG=false is now true). Pydantic’s lenient bool coercion encodes the full, case-insensitive true/false vocabulary once, correctly, so every boolean field on every model gets the same right answer. The convenience of coercion here is not laziness — it is the elimination of a bug class that a surprising number of production incidents trace back to. A feature flag that is stuck on because someone wrote FLAG=false and the code did bool() is a genuinely common outage, and a typed bool field makes it impossible.
The same reasoning extends to integers and floats, just less dramatically. int("8080") works, but int("8080 ") with a trailing space, or int("8_080"), or an empty string, each behaves in a way you have to remember, whereas the model’s coercion handles the normal cases and rejects the genuinely invalid ones with a clear, field-named error. Letting the model own numeric coercion means you never accumulate a drawer full of slightly-different hand-rolled parsers, each with its own quirks, scattered across the codebase.
Coercing collections: JSON, not comma-separated
The behaviour that surprises people most is how list and dict fields parse from the environment. Pydantic-settings expects JSON for a complex field, not a delimiter-separated string: HOSTS=["a","b"] populates a list[str], but HOSTS=a,b raises a validation error, because pydantic does not guess that you meant comma separation. This is a coercion decision worth understanding rather than fighting — a comma is a legitimate character inside a value, so there is no delimiter pydantic could pick that would be right for every case, and JSON is the unambiguous choice. If you want comma-separated input for operator convenience, you add a @field_validator(mode="before") that splits the string into a list before pydantic validates it, making the CSV-to-list conversion an explicit, tested step rather than an assumed one. A common shape is a validator that accepts either form: if it receives a string, split it on commas and strip each item; if it already received a list (from a JSON value), pass it through unchanged. That gives operators the comma-separated convenience they expect while still accepting the canonical JSON form, and because the branching lives in one validator it is easy to test both paths. The rule mirrors the scalar case: lenient coercion handles the obvious string-to-type conversions, and anything ambiguous is left for you to declare deliberately.
When to reach for strict mode
Because the default is lenient and that default is right for most settings fields, strict mode is the exception you justify rather than the rule you apply. The decision comes down to the source of the value and the cost of a wrong-but-coercible input. A value that arrives as an environment string wants leniency: the string is the transport, and coercion is the necessary translation. A value that arrives already-typed — from a parsed JSON body, a message payload, or a programmatic call — wants strictness when a type mismatch would signal an upstream defect you would rather catch than paper over. And a value whose exact form carries meaning, where accepting a “close enough” coercion would be silently wrong, wants strictness plus an explicit validator.
A concrete example makes the trade-off tangible. Suppose a replicas field is normally set by an operator through an environment variable but is sometimes populated from a JSON config a control plane pushes. If you keep it lenient, "3" from the environment works and 3.0 from a malformed JSON payload silently becomes 3 — hiding the fact that the control plane sent a float where it should have sent an integer. If you make it strict, the environment’s "3" is rejected, breaking the operator path. The resolution is to decide which source is authoritative: if the environment is the real interface, stay lenient and validate the JSON path separately; if the JSON is the real interface, make the field strict and have the operator-facing layer parse before it reaches the model. Strictness is not free safety — it is a choice about which inputs you are willing to reject, and it should follow the field’s true source of truth.
The failure mode of getting this wrong is instructive in both directions. Flip a whole settings model to strict=True in a burst of caution and the next deploy fails at boot because every environment-sourced int and bool field now rejects the strings the environment delivers — a self-inflicted outage from an over-correction. Leave a JSON-sourced numeric field lenient when it should be strict and a producer that starts sending "3" instead of 3 sails through, its type bug absorbed by coercion, until the day the string is something coercion cannot handle and the failure lands far from its cause and takes far longer to trace back to the field that quietly accepted the wrong shape. Both are avoidable by the same habit: decide strictness per field, driven by where the value comes from, and never as a blanket setting applied out of general anxiety. The middle path — lenient by default, strict where a typed source makes a coercion suspicious, constrained everywhere — is what keeps a configuration both convenient for operators and honest about upstream bugs.
Deployment parity: local to production
- Local dev — env strings are coerced; a non-numeric
PORTfails immediately with a clear message. - CI — test both a valid and an invalid value per coerced field to lock in behaviour.
- Staging/Production — identical coercion rules mean a value that validated in CI validates at boot.
The reason coercion is a parity concern at all is that it is where a value’s meaning is fixed, and you want that meaning fixed identically in every environment. If DEBUG=false parses to False in production, it must parse to False in CI and on a laptop too — and because the same model with the same coercion rules runs in all three, it does. The failure this prevents is subtle: a hand-rolled parser that treats "false" differently in one environment than another (because someone used bool() in one place and a string compare in another) produces a flag that is off in staging and on in production for the same input. Routing every coerced value through one model with one set of rules makes that divergence impossible, which is why the CI step should test both a valid and an invalid value for each coerced field — it locks the coercion behaviour in as a tested contract, not an incidental result.
The practical CI pattern is a small parametrised test: for each coerced field, assert that a representative valid input constructs and a representative invalid input raises ValidationError. That test is cheap, and it turns “we think PORT=abc fails” into a proven fact, so a future pydantic upgrade or a well-meaning refactor that changes coercion behaviour breaks the test in CI rather than the service in production.
Security boundaries & guardrails
- Decide coercion per field deliberately; do not flip the whole model to strict on a whim.
- Range-check coerced numbers with
Field(ge=, le=)— coercion does not bound the value. - Keep
extra="forbid"so unexpected keys never reach a coercion path. - Never call
bool()orint()on a raw env string outside the model; let pydantic parse it. - Wrap secrets in
SecretStrregardless of strictness.
The range-check guardrail is the one people most often skip and most need. Coercion answers “is this the right type?” but says nothing about whether the value is in a sensible range. "70000" coerces cleanly to the integer 70000, which is a perfectly good int and a completely invalid port, so a field typed only int accepts it and the failure moves downstream to whenever something tries to bind that port. Adding Field(ge=1, le=65535) turns the out-of-range value into a startup ValidationError naming the field — the coercion produced a valid int, and the constraint then rejected an unreasonable one. Type and range are two separate checks, and a settings field usually wants both.
The “never call bool()/int() yourself” guardrail is really a restatement of the single-boundary principle. Every hand-rolled parse outside the model is a second place where a value’s type is decided, and second places drift from the first — one uses int(), another forgets and compares a string, a third reintroduces the bool() truthiness bug. The model is the one component that knows the declared type and the coercion rules, so it must be the only component that converts. When you find yourself writing int(os.environ[...]), the fix is not to write it more carefully; it is to add a typed field and delete the manual parse.
Troubleshooting
"0"treated asTrue— you are callingbool("0")yourself; route the value through the model instead.- Float silently truncated — lenient
intrejects"1.5"; if you need truncation, validate and convert explicitly. - Strict field rejects an env var — env vars are strings; a
Strict()int cannot accept"3". RemoveStrict()for env-sourced fields. - Inconsistent behaviour across versions — pin pydantic v2; see Migrate to pydantic-settings v2.
Notice that three of those four symptoms trace to the same root cause: doing the conversion by hand instead of letting the model do it. The "0"-is-truthy bug is bool() on a raw string; the “strict field rejects an env value” surprise is asking strict mode to do a job that belongs to lenient coercion; and inconsistent cross-version behaviour is what you get when parsing logic is scattered rather than centralised in one pinned dependency. The unifying fix is the theme of this whole page — coercion is the model’s job, decided per field, and every place your code reaches around the model to parse a value itself is a place a coercion bug can hide.
The float-truncation symptom is the exception that proves the rule. Lenient int rejecting "1.5" is not a bug to work around but the type system refusing to silently lose data. If your domain genuinely wants truncation — say, a “round down to whole seconds” semantic — encode it as a @field_validator that takes a float and returns an int, so the lossy step is explicit, named, and testable. The moment truncation is a deliberate line of code rather than an accident of coercion, it stops being a surprise.
Frequently asked questions
Does pydantic coerce environment variable strings automatically?
Yes. Because environment variables are always strings, pydantic-settings coerces "8080" to int 8080 and "true" to bool True by default. That is usually what you want for env vars, but you can opt into strict mode per field.
How do I make a single field strict while leaving others lax?
Annotate it with Strict() — port: Annotated[int, Strict()] — or set strict=True on the Field. The rest of the model keeps its lenient coercion.
Why does “False” become True for a bool field?
That happens only with the built-in bool(), not pydantic. Pydantic correctly parses "false", "0", "no", and "off" to False. If you see the bug, you are bypassing the model and calling bool() on the raw string.
Key takeaways
The invariant: coercion is a per-field decision, lenient for env strings and strict for already-typed inputs, with explicit range checks on every number. Never reimplement parsing outside the model. Those three sentences carry the whole page. Leniency is the correct default for a settings model because its inputs are environment strings; strictness is a targeted tool for the minority of fields fed from already-typed sources where a coercion would mask a bug; and a range constraint is what turns a valid-typed-but-unreasonable value into a loud startup error instead of a downstream failure.
The practical discipline is to look at each field and ask two questions. Where does this value come from? — the environment means lenient, a structured source means consider strict. And what values are actually valid? — a type alone rarely answers this, so add Field(ge=, le=), an enum, or a validator to bound it. Answer both per field and coercion stops being the mysterious layer that occasionally does something surprising and becomes a deliberate, documented part of your configuration contract. And keep every conversion inside the model: the truthy-string bug, the version-drift bug, and the double-source bug all disappear the moment the model is the only thing that parses.