Fix pydantic ValidationError on startup
A ValidationError when your app boots is pydantic working exactly as designed — it caught a configuration problem before the first request. The fix is to read the error, not to suppress it. This page decodes the common cases, extending Strict Mode & Type Coercion.
The instinct a startup ValidationError provokes is exactly wrong. Faced with a process that refuses to boot, the tempting move is to make the error go away — wrap the construction in a try/except, fall back to defaults, reach for model_construct — so the service starts. But a settings model that raises at startup is telling you something true and important: the configuration this process was given is invalid, and running anyway means running with configuration you know is wrong. A crash is visible, actionable, and safe by comparison; a process that started with broken configuration is invisible, serves wrong results, and fails somewhere far from the cause. The entire value of validating configuration at startup is that it converts a diffuse, late, mysterious failure into a single, early, precise one — and suppressing the error throws that value away.
So the mindset for this page is diagnostic, not defensive. A ValidationError is not a problem with your code; it is a precise bug report about your configuration, and pydantic has already done the hard part by telling you exactly which field failed, why, and with what input. The work is to read that report and fix the variable it names. Everything below is about decoding the report quickly and preserving the fail-fast behaviour that makes it useful.
Problem 1: swallowing the error
# ANTI-PATTERN: hides the one error that should be fatal
try:
settings = Settings()
except ValidationError:
settings = Settings.model_construct() # boots with INVALID config
model_construct skips validation, so the app runs with broken configuration — far worse than crashing. model_construct exists for a legitimate, narrow purpose — building a model instance from data you have already validated, skipping the work a second time for performance — but using it to escape a startup error weaponises it against yourself. The resulting object has whatever partial, malformed, or missing values triggered the error in the first place, and now the rest of the application treats it as a valid config. The database URL that was unset is now None or an empty string, and instead of a clear “database_url is required” at boot you get an opaque connection error under load an hour later, with nothing in the connection error pointing back to the missing configuration that actually caused it.
The same critique applies to the softer variants of this anti-pattern. Catching ValidationError and falling back to a hard-coded default set means the process runs on values nobody deployed. Catching it and logging a warning before continuing means the one signal that should stop the process is downgraded to noise in a log nobody reads. Even a bare except Exception around the construction — added to “make startup more robust” — swallows the very error whose whole job is to stop an unsafe start. Robustness at startup does not mean surviving bad configuration; it means refusing to run on it, loudly and immediately, so the bad configuration is fixed rather than tolerated.
Problem 2: not reading the field path
# The error tells you exactly what's wrong — read it.
2 validation errors for Settings
database_url
Field required [type=missing]
port
Input should be a valid integer [type=int_parsing], input_value='8O80'
database_url is unset; port has a letter O instead of a zero. Both are named precisely. This output is a structured document, not a stack trace, and reading it in the right order makes the fix obvious. The first line — “2 validation errors for Settings” — tells you pydantic collected every failure rather than stopping at the first, so you fix the whole batch in one pass instead of one failed boot at a time. Each subsequent stanza has three parts worth reading deliberately: the field path (port) tells you which variable to look at; the message (“Input should be a valid integer”) tells you what pydantic wanted; and the bracketed error type plus the input value (type=int_parsing, input_value='8O80') tell you exactly what it got. Here the input '8O80' makes the typo jump out — a capital letter O where a zero belongs — which no amount of staring at the environment variable’s name would have revealed.
The field path is more powerful than it first looks because it addresses nested and grouped fields precisely. A failure inside a sub-model reports its full path — database.port rather than just port — so even in a deeply structured configuration the error points at the exact leaf that is wrong. When you have a ValidationError in hand, the single most useful habit you can build is to read the loc first, before anything else in the message: it converts “something in the config is wrong” into “this specific variable is wrong”, which is the difference between a five-minute fix and an afternoon of guessing. It is also why a common triage mistake is to read the human-readable message first and the loc last: the message tells you the shape of the problem, but the loc tells you where, and where is what you act on. Train yourself to read the field path before anything else and most configuration errors resolve almost immediately.
Secure implementation
# config/bootstrap.py
import sys
from pydantic import ValidationError
from config.settings import Settings
def load() -> Settings:
try:
return Settings()
except ValidationError as exc:
# Report which fields failed (names only) and exit non-zero.
for err in exc.errors():
loc = ".".join(str(p) for p in err["loc"])
print(f"config error: {loc}: {err['msg']}", file=sys.stderr)
sys.exit(1) # fail fast; never boot on invalid config
settings = load()
exc.errors() gives a structured list of loc (field path) and msg. Log those, exit non-zero, and keep the fail-fast contract. Never unwrap secret values into the log.
The shape of this bootstrap matters as much as the fact of it. It catches ValidationError specifically — not a broad Exception — so only a configuration validation failure is handled and any other error propagates normally. It iterates exc.errors() to print every failing field, so an operator sees the complete list of what to fix rather than discovering them one redeploy at a time. It logs the field path and message but deliberately not err["input"], because the offending input for a secret field would be the secret itself. And it ends in sys.exit(1) — a non-zero exit — which is the crucial bit: the process dies, so the orchestrator’s health check sees an immediate failure and keeps the previous healthy instances serving, instead of rolling a broken one into the fleet.
That interaction with the orchestrator is why fail-fast is safe rather than reckless. In a rolling deploy, a new instance that exits non-zero on invalid configuration never becomes healthy, so it never receives traffic and the deploy stalls with the old version still serving — a visible, self-limiting failure. Compare the alternative: an instance that swallows the error and starts “successfully” passes its health check, receives traffic, and serves errors, and now the rollout has replaced good instances with broken ones. Exiting non-zero on a configuration error is what lets the platform protect you; suppressing the error defeats that protection precisely when you need it.
Gotchas & version-specific behaviour
[type=missing]means a required variable is unset — check the name and the environment.[type=int_parsing]/[type=bool_parsing]means a value is the wrong shape; inspectinput_value.extra_forbiddenmeans a variable is set that the model does not declare — a typo or a stale key.[type=value_error]comes from your own@field_validator— the message is the one you wrote.- Print field names only;
err["input"]may contain a secret, so do not log it for secret fields.
Knowing the error-type vocabulary turns diagnosis into a lookup. [type=missing] is the most common and the simplest: a required field received no value from any source, so either the variable is genuinely unset in this environment or its name is misspelled where it is set — check both the field name and the actual environment. [type=int_parsing], [type=bool_parsing], and their siblings mean a value was present but the wrong shape for the declared type, and the input_value in the message shows you the culprit, which is almost always a typo, a stray space, a quoting problem, or a value copied from the wrong environment. [type=extra_forbidden] is the fingerprint of extra="forbid" doing its job: a variable is set that the model does not declare, which means a typo in the variable name or a stale key left over from a removed field. And a [type=value_error] comes from one of your own @field_validators raising ValueError, with your custom message attached — which is exactly why writing clear validator messages pays off, since this is where an operator reads them.
The secret-redaction gotcha deserves its own emphasis because it is easy to get wrong while trying to be helpful. The natural instinct when handling a validation error is to log everything you have, including err["input"], so the operator can see the bad value. For most fields that is fine and useful. For a secret field it is a leak: the “bad input” for a SecretStr password is the password itself, and printing it into a startup log hands it to everyone with log access. The safe rule is to log the field path and the message but never the input value, or to redact the input for any field you know is sensitive. You lose nothing diagnostically — the field name and error type already tell you what to fix — and you avoid turning an error handler into a credential-disclosure path.
Production parity checklist
- The error is logged with field paths and the process exits non-zero.
- No
model_constructfallback that boots on invalid config. - CI constructs
Settings()so these errors surface before deploy. - Secret values are never printed in the error handler.
extra="forbid"is kept so typos appear asextra_forbidden.
The CI item is the one that moves the whole error left. A startup ValidationError caught in production is already far better than a mid-request failure, but caught on the pull request it is better still — the misconfiguration never ships at all. A job that constructs Settings() against each environment’s real variable set turns “did staging get the new variable?” into a red check, and because the model is the authoritative list of what the service needs, a successful construction proves the configuration is complete and well-typed. Pair that with keeping extra="forbid", and the same job also catches the inverse mistake: a variable set but no longer read, surfacing as extra_forbidden before it becomes a confusing leftover.
Key takeaways
A startup ValidationError is a precise bug report — read the loc, fix the variable, and preserve the fail-fast exit. The temptation to make the error disappear is the one thing to resist: model_construct, a defaults fallback, or a broad except all trade a loud, early, precise failure for a quiet, late, mysterious one, which is a bad trade every time. The error is doing you a favour by stopping the process before it can serve a single request on bad configuration.
The workflow is short once the instinct is right. Read the first line to see how many fields failed; for each, read the loc to know which variable, the type to know the category of problem, and the input value (for non-secret fields) to see the actual bad data. Fix the variable that is wrong, not the model that correctly rejected it. Keep the bootstrap that reports field paths and exits non-zero so the orchestrator can protect the fleet, and never log a secret field’s input. Add a CI step that constructs Settings() against each environment so these errors surface on the pull request instead of at deploy, and the startup ValidationError becomes what it was always meant to be: the cheapest possible place to catch a misconfiguration. For the underlying coercion rules behind *_parsing errors, see Strict Mode & Type Coercion.