Blog
Backend2026-W205 min read

Fail Fast at the Right Boundary — Pydantic Settings in Production

Hardcoded API key defaults work locally and ship secrets to production. A model_validator that refuses to start without the key turns silent misconfig into a loud startup error.

A solid brass padlock with its key still inserted, resting on a worn leather-bound logbook with handwritten entries, soft warm desk light, no people, editorial.

The problem

Configuration defaults that work in development are configuration defaults that break in production. The classic example: a settings class with a hardcoded API key as the default value. It works locally because the key is valid. It works in CI because the test environment inherits the same default. It fails silently in production only after the first email gets queued and not sent — or worse, it sends from the wrong account because the default key is someone's personal credentials that got checked in years ago.

The version of this problem I cleaned up: a BREVO_API_KEY field with a real transactional email key hardcoded as the Pydantic default value. The key had been in version control long enough that it was baked into the git history. It worked fine. It was also a secret in plaintext in a public-facing codebase.

The approach

Two changes, in order.

First: remove the default. BREVO_API_KEY: str = "" — empty string, no real credential. The application will start locally without it (dev doesn't send email), and the key gets injected in production via environment variable or secret manager.

Second: add a model_validator that makes the application fail to start if the key is missing in a production context:

from pydantic import model_validator ENVIRONMENT: str = "development" BREVO_API_KEY: str = "" @model_validator(mode="after") def _validate_production_secrets(self) -> "Settings": if self.ENVIRONMENT == "production" and not self.BREVO_API_KEY: raise ValueError( "BREVO_API_KEY must be set in production. " "Set ENVIRONMENT=development to bypass this check locally." ) return self

The validator runs after all fields are populated. If ENVIRONMENT=production and the key is empty, the application refuses to start. The error message is explicit about what to set and how to bypass it locally — because someone will inevitably try to start a production-like environment locally and hit this validator, and they need to know the escape hatch immediately.

What I learned

The ENVIRONMENT field is doing two jobs here: it gates the validator, and it's available to the rest of the application for things like Sentry environment tagging. That's fine — as long as ENVIRONMENT defaults to "development" and the production value has to be set explicitly, the validator only fires when you actually intend to run in production.

The reason for mode="after" (rather than mode="before") is that you want all fields to be resolved from their environment variables before the cross-field validation runs. With mode="before" you'd be validating against raw input, not the final resolved values, and field references would come back as strings from the unprocessed data dict rather than their typed Python values.

One trap: Pydantic v2's model_validator replaces the v1 validator and root_validator decorators. The mode="after" variant receives a fully constructed instance (self), while mode="before" receives a dict of raw input. Pick based on whether you need the final typed values (after) or want to intercept before any coercion (before).

The fail-fast validator doesn't prevent secret sprawl — the key still needs to be rotated if it was ever committed. But it does prevent the class of bug where a stale default silently operates in production, and it makes the operational requirement explicit in the code rather than in a deployment runbook that nobody reads.