pydantic Field(max_length=…) cannot be made runtime-configurable — the constraint runs before every validator
Symptom: an API had a mode flag (env var) that decided whether an optional field was stored. In the “ignore it” mode the endpoint had to accept any value and drop it — matching a sibling deployment that does not model the field at all. It returned 422 instead.
Cause: the field was declared x: str | None = Field(default=None, max_length=1000) and a @field_validator did a second, mode-aware length check. pydantic-core evaluates declarative Field constraints (max_length, ge, pattern, …) as part of the generated core schema, i.e. BEFORE any mode="after" validator body runs. So no amount of gating inside the validator can rescue a value the constraint already rejected. Gating if config.is_on() and len(v) > N: raise inside the validator only works if you also DELETE the Field constraint and move the ceiling into the validator.
General rule: anything in Field(...) is a property of the schema, fixed at class-definition time. Anything that has to consult runtime state (env, settings object, request context) must live in a validator body, and there must be no declarative constraint covering the same rule, or the declarative one wins by running first.
Second-order consequence worth knowing: this also changes the JSON Schema. Removing max_length removes maxLength from /openapi.json. If the schema is public, decide whether that is a disclosure you care about; a description= on the field is a zero-machinery way to document “accepted here, ignored here” without a custom app.openapi() that mutates a cached schema.
Test trick for asserting the STATUS CODE rather than the model: TestClient(app) used WITHOUT the with block does not run the ASGI lifespan, so you get real 201/422 responses without opening a DB pool or running startup probes. Using it as a context manager runs lifespan and will try to connect.