Cross-Field Validation with model_validator

Some configuration mistakes are not visible in any single value. DEBUG=true is fine — until ENVIRONMENT=production. A read-replica URL is fine, unless the primary is missing. Disabling TLS verification is a legitimate local convenience and an incident anywhere else. Rules like these span fields, so no field validator can express them, and without them the invalid combination boots happily and shows up later as behaviour. This page implements them as boot-time errors, extending the custom validators and field constraints topic.

Problem 1: a rule that a field validator cannot see

# ANTI-PATTERN: this validator has no access to environment
class Settings(BaseSettings):
    environment: str
    debug: bool

    @field_validator("debug")
    @classmethod
    def _check(cls, value: bool) -> bool:
        # nothing here can tell whether this is production
        return value

A field validator receives its own value and nothing else, which is by design — it runs during field parsing, when other fields may not have been processed yet. Any rule of the form “X is only valid when Y is Z” therefore cannot live there, and attempts to work around it by reading os.environ inside the validator reintroduce a second source of truth that ignores every override the model applies.

model_validator(mode="after") runs once, on the fully-populated and validated object, with every field available as a typed attribute. That is the correct home for these rules, and the resulting code reads exactly like the sentence it encodes.

Where each kind of validator runs A before-validator sees raw input, field validators run per field during parsing, and an after-validator runs once on the fully populated model where cross-field rules can be expressed. before raw, untyped input field validators one field at a time after every field, typed boot a cross-field rule can only be expressed in the third box
Field validators run too early to see each other; the after-validator is where combinations become checkable.

Problem 2: correcting instead of rejecting

# ANTI-PATTERN: the mistake is hidden, and two environments now behave identically
@model_validator(mode="after")
def _force_debug_off(self) -> "Settings":
    if self.environment == "production":
        self.debug = False        # silently overridden; nobody learns anything
    return self

Silently correcting looks defensive and is worse than failing. The environment is still configured wrongly — somebody believes DEBUG=true is in effect and it is not — so the next person to debug an issue there works from a false premise. Worse, the same manifest deployed elsewhere behaves differently, and nothing in either environment reveals why.

Raise instead. The deploy stops, the message names the offending combination, and the person who wrote it fixes the manifest. Correction has one legitimate use: normalising representations, like stripping whitespace or lowercasing an environment name, where the value’s meaning is unchanged. Anything that changes meaning should refuse.

Problem 3: an error message that says nothing

ValidationError: Value error, invalid configuration

The rule fired, the deploy stopped, and the operator has no idea which of forty variables is the problem. The message costs the same to write well: naming both fields, both values and the rule turns a support conversation into a one-line fix.

There is a caveat worth knowing. Error messages from configuration validation are printed in CI logs and container logs, so they must never include a credential’s value. Name the field and describe the problem — “must be set when tls_verify is false” — rather than echoing what was supplied, which is a discipline that costs nothing and prevents a diagnostic from becoming a disclosure.

Secure implementation

# config.py — rules that span fields, each raising with an actionable message
from pydantic import SecretStr, PostgresDsn, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(extra="forbid")

    environment: str = "local"
    debug: bool = False
    tls_verify: bool = True
    database_url: PostgresDsn
    replica_url: PostgresDsn | None = None
    admin_token: SecretStr | None = None
    admin_enabled: bool = False

    @property
    def is_deployed(self) -> bool:
        return self.environment in {"staging", "production"}

    @model_validator(mode="after")
    def _check_combinations(self) -> "Settings":
        # 1. the rule reads like the sentence it encodes
        if self.debug and self.environment == "production":
            raise ValueError("debug must be false when environment is production")

        # 2. a local convenience that must never survive into a deployed environment
        if not self.tls_verify and self.is_deployed:
            raise ValueError(
                f"tls_verify cannot be false when environment is {self.environment}"
            )

        # 3. a dependency between two optional fields
        if self.admin_enabled and self.admin_token is None:
            # 4. names the missing field without echoing any value
            raise ValueError("admin_token is required when admin_enabled is true")

        # 5. a rule about two values that must not be identical
        if self.replica_url is not None and self.replica_url == self.database_url:
            raise ValueError("replica_url must differ from database_url")

        return self

Grouping the rules into one validator keeps them readable as a set, which matters because their interactions are the interesting part — a reader can see at a glance every combination the model refuses. Splitting them across several validators is fine too, and is worth doing once the list grows past six or seven, at which point grouping by subject (database rules, security rules) reads better than one long function.

Note that every rule raises ValueError rather than a custom exception. Pydantic wraps it into a ValidationError with the field location attached, which means the resulting message includes the model context and the error is caught by the same handling that catches every other configuration problem. A custom exception type would escape that machinery and need its own handling at each construction site.

Four combinations a settings model should refuse Debug enabled in production, TLS verification disabled in a deployed environment, an admin interface enabled without a token, and a replica URL identical to the primary. debug = true, environment = production tls_verify = false, environment = staging admin_enabled = true, admin_token = none replica_url = database_url tracebacks and settings served publicly traffic to an unverified endpoint an admin surface with no auth reads hitting the primary under load
Each is individually valid and jointly a production incident — which is exactly what a cross-field rule is for.

Choosing which rules are worth encoding

Not every conceivable combination deserves a rule. The ones that earn their place share a shape: the invalid combination is plausible enough that somebody will produce it, and its consequences are worse than a failed deploy. debug in production qualifies on both counts — it is one variable away from a normal configuration, and the result is a public traceback page exposing configuration and query details.

The rules that are not worth writing are the ones guarding against combinations nobody would produce, or where the consequence is a quick, obvious failure anyway. A validator asserting that a port is not simultaneously two values is guarding against nothing; a validator asserting that a pool size is smaller than a connection limit may be worth it if exceeding it produces a slow, confusing degradation rather than an immediate error.

A useful test when deciding: would you rather find out about this combination from a failed deploy, or from the behaviour it causes? If the answer is the deploy, encode it. If the behaviour is immediate and obvious — the service fails to start anyway, loudly, for a different reason — the rule is redundant and just one more thing to maintain.

Finally, keep security rules in this category rather than in documentation. “TLS verification must be on in deployed environments” written in a runbook is a hope; written as a validator it is a property of every deploy, including the emergency one at two in the morning when somebody is tempted to disable a check to get past an error.

Testing these rules is unusually cheap and unusually valuable, because each is a two-line statement about a combination that must never ship. Parametrise over the invalid combinations, assert that construction raises, and assert on the error type rather than the message wording so a rephrasing does not break the suite. Pair each with the adjacent valid case — debug=true with environment=local — so the boundary is visible in one screen, and a future reader can see exactly where the line is drawn rather than inferring it from the validator’s source.

Gotchas and version-specific behaviour

  • mode="after" receives the validated model instance; mode="before" receives raw input where nothing is typed yet.
  • Return self from an after-validator — forgetting to return leaves the model as None and produces a confusing error.
  • Raise ValueError, not a custom exception, so pydantic wraps it with the model context.
  • Never echo a credential’s value in an error message; name the field and describe the rule.
  • Prefer refusing to correcting, except for normalisations that do not change meaning.
  • A frozen model cannot be mutated in an after-validator, which is a good reason to use one.
  • Rules that reference the environment name should treat the set of deployed environments as a single property, so adding a new environment does not mean editing every rule.

Production parity checklist

  • Every “only valid when” rule is a model_validator(mode="after"), not a comment or a runbook line.
  • debug and any verification-disabling flag are refused in deployed environments.
  • Optional fields that depend on each other are checked as a pair, so enabling a feature without its credential fails at boot.
  • Every error message names both fields and the rule, and no message echoes a secret value.
  • Rules raise rather than correct, except for representation normalisation such as stripping whitespace or lowercasing a name.
  • Tests cover each rule with a passing and a failing case, asserting the error type rather than its wording so a rephrasing does not break the suite.
  • The set of deployed environments is a single property the rules reference, so adding an environment does not mean editing every rule.
Deciding whether a combination deserves a rule A rule is worth writing when the invalid combination is plausible and its consequence is worse than a failed deploy; otherwise it is maintenance without benefit. worth a rule plausible one-variable mistake consequence is silent or severe security posture depends on it prefer a failed deploy not worth one nobody would configure it it fails immediately anyway the rule needs constant updating maintenance without benefit
The test: would you rather learn about this from a failed deploy or from the behaviour it causes?

Frequently asked questions

When should a rule be a model_validator rather than a field validator?

Whenever it depends on more than one field. A field validator sees only its own value — by design, since it runs while fields are still being parsed — so any rule of the form “this is only valid when that is set” belongs on the model, after every field is populated. Reaching for os.environ inside a field validator to work around the limitation is worse than the problem: it bypasses every source and override the model applies, so the rule ends up checking something different from what the application will actually use.

What is the difference between mode=“before” and mode=“after”?

Before runs on the raw input, so fields are unvalidated, untyped and possibly absent; after runs once every field has been parsed and validated, giving you a fully-typed model instance. Cross-field rules need the second, because they compare real values rather than whatever strings happened to arrive. Use before only when you are genuinely reshaping input — renaming keys, unpacking a combined value — and after for everything that checks a rule.

Can a validator modify values instead of raising?

It can, and it usually should not. Silently correcting a configuration hides a mistake and makes two environments behave identically despite being configured differently, which is a genuinely confusing thing to debug later. Raise so the deploy stops and the manifest gets fixed. The exception is normalisation that does not change meaning — stripping whitespace, lowercasing an environment name — where the value is the same value in a canonical form.

How do I write the error so it is actionable?

Name both fields, both values where they are not sensitive, and the rule. “debug must be false when environment is production (got debug=true)” tells an operator exactly what to change; “invalid configuration” starts a conversation. Keep credential values out of messages entirely — name the field and describe the requirement instead — because these messages are printed in CI logs and container logs that are read far more widely than production application logs.

Do cross-field rules slow down startup?

No — they run once, on one object, at import. The cost is microseconds even with dozens of rules, and the comparison is not with a faster startup but with discovering the same problem from user-visible behaviour hours later. If a rule were genuinely expensive — one that made a network call, say — that would be a sign it belongs somewhere else, such as a readiness check, rather than an argument against cross-field validation as a technique.

Key takeaways

The mistakes that reach production are rarely a single wrong value; they are combinations where each part is individually reasonable. model_validator(mode="after") is where those become checkable, because it runs once on a fully-typed model with every field available, and the rules it contains read like the sentences they encode.

Raise rather than correct, so a wrong manifest gets fixed instead of quietly overridden, and write messages that name both fields and the rule without echoing any credential. Encode the rules where the invalid combination is plausible and its consequence is silent or severe — debug in production, verification disabled outside development, an admin surface without a token — and skip the ones guarding against things nobody would configure. That leaves a short, readable list of exactly the combinations your service refuses to start with. See the custom validators and field constraints topic for single-field rules.