Typorithm · medium

Field validators: normalize before, enforce meaning after

pythonpydanticindependent-practice
Saved on this device
Full lesson · 4 learning steps Open any exercise for focused practice
1

Step 1 of 4 · intro card

Field validators: normalize before, enforce meaning after

0%

Concept and contract

Separate a permitted normalization from a domain check and always return the checked value.

A field validator should have a precise job. A before validator receives external input and may normalize an explicitly supported representation; an after validator receives the value after the field type has been checked. Combining those jobs without naming the stages makes it hard to tell which values are actually accepted.

Our slug boundary permits surrounding whitespace and letter case differences. It normalizes strings before ordinary string validation, then checks a small slug grammar afterward. The grammar is a local application rule, not a complete international identifier policy. A numeric input is left for the declared string type to reject rather than being silently turned into text.

Mechanism and worked example

The before method checks isinstance(value, str) before calling string operations.

The before method checks isinstance(value, str) before calling string operations. The after method then receives a string, applies a full-match grammar and returns that value. A normalized " Backend " becomes backend; a value containing a plus sign fails the domain check.

Each decorator names the field and the validation mode, and each class method returns the value needed by the next stage. The fixture distinguishes a field type error from a domain value_error. Those error types are intentionally different evidence: one input was not a string, while another was a string outside the allowed grammar.

Complete worked example

python

import re
from pydantic import BaseModel, ValidationError, field_validator

class Slug(BaseModel):
    value: str

    @field_validator("value", mode="before")
    @classmethod
    def normalize(cls, value):
        return value.strip().lower() if isinstance(value, str) else value

    @field_validator("value", mode="after")
    @classmethod
    def grammar(cls, value):
        if not re.fullmatch(r"[a-z][a-z0-9-]*", value):
            raise ValueError("invalid slug")
        return value

def inspect_example(payload):
    try:
        return {"value": Slug.model_validate(payload).value}
    except ValidationError as error:
        return {"error": sorted({e["type"] for e in error.errors()})}
Reasoning
The before method checks isinstance(value, str) before calling string operations. The after method then receives a string, applies a full-match grammar and returns that value. A normalized " Backend " becomes backend; a value containing a plus sign fails the domain check. Each decorator names the field and the validation mode, and each class method returns the value needed by the next stage. The fixture distinguishes a field type error from a domain value_error. Those error types are intentionally different evidence: one input was not a string, while another was a string outside the allowed grammar.

Focused decision: use the complete example for context

python

import re
from pydantic import BaseModel, field_validator

class Slug(BaseModel):
    value: str

    @field_validator("value", mode="after")
    @classmethod
    def grammar(cls, value):
        if not re.fullmatch(r"[a-z][a-z0-9-]*", value):
            raise ValueError("invalid slug")
        return value
Focused decision
Reconstruct this focused decision after the warm-up. The complete imports, model or application, and test client remain in the worked example. Typing records practice; only the downloadable computer lab executes Python.

Counterexamples and common mistakes

A validator that forgets to return its value can replace a valid field with None.

A validator that forgets to return its value can replace a valid field with None. Casting every raw value to str can also widen the boundary unexpectedly. In current Pydantic, TypeError raised inside a validator is not automatically treated as a normal ValidationError, so do not use it as a substitute for a deliberate domain rejection.

Raise ValueError for a failed domain condition and let genuine programming errors remain visible to tests. Avoid mutating a raw input object that might later be examined by another validator or union branch. The controlled mistake here removes the grammar rejection, allowing a superficially string-shaped but invalid slug through.

Controlled mistake: observe the actual wrong behavior

python

import re
from pydantic import BaseModel, ValidationError, field_validator

class Slug(BaseModel):
    value: str

    @field_validator("value", mode="before")
    @classmethod
    def normalize(cls, value):
        return value.strip().lower() if isinstance(value, str) else value

    @field_validator("value", mode="after")
    @classmethod
    def grammar(cls, value):
        if False:
            raise ValueError("invalid slug")
        return value

def inspect_example(payload):
    try:
        return {"value": Slug.model_validate(payload).value}
    except ValidationError as error:
        return {"error": sorted({e["type"] for e in error.errors()})}
Reasoning
A validator that forgets to return its value can replace a valid field with None. Casting every raw value to str can also widen the boundary unexpectedly. In current Pydantic, TypeError raised inside a validator is not automatically treated as a normal ValidationError, so do not use it as a substitute for a deliberate domain rejection. Raise ValueError for a failed domain condition and let genuine programming errors remain visible to tests. Avoid mutating a raw input object that might later be examined by another validator or union branch. The controlled mistake here removes the grammar rejection, allowing a superficially string-shaped but invalid slug through.

Independent practice: one bounded change

Add a maximum normalized slug length of eight characters.

Add a maximum normalized slug length of eight characters. Preserve the current whitespace and lowercase normalization and the grammar rule. The new bound belongs in the after stage, where the value already has the expected type.

Test an exactly eight-character slug and a nine-character slug. Then recheck an invalid character and a numeric input so the added condition does not accidentally replace existing checks. The exercise requires a short guard and a ValueError, not a custom validation framework or an additional parse pass.

Step 1: Reject normalized slugs longer than eight characters while retaining type and grammar checks. Step 2: Retain the working imports and application structure. Change the stated policy, run the supplied practice cases, then explain the newly accepted or rejected boundary.

Practice starter: solve before reading the solution

python

import re
from pydantic import BaseModel, ValidationError, field_validator

class Slug(BaseModel):
    value: str

    @field_validator("value", mode="before")
    @classmethod
    def normalize(cls, value):
        return value.strip().lower() if isinstance(value, str) else value

    @field_validator("value", mode="after")
    @classmethod
    def grammar(cls, value):
        if not re.fullmatch(r"[a-z][a-z0-9-]*", value):
            raise ValueError("invalid slug")
        return value

def inspect_example(payload):
    try:
        return {"value": Slug.model_validate(payload).value}
    except ValidationError as error:
        return {"error": sorted({e["type"] for e in error.errors()})}

# Independent task: Reject normalized slugs longer than eight characters while retaining type and grammar checks.
Reasoning
Reject normalized slugs longer than eight characters while retaining type and grammar checks.
  • Hint 1: The after validator already owns the normalized string.
  • Hint 2: Add a len(value) > 8 guard before its final return.

Solution, tests and trade-offs

The solution checks length after the grammar condition and before returning the string.

The solution checks length after the grammar condition and before returning the string. The acceptance cases show that platform is accepted and developer is rejected. A plus sign still fails the grammar and a number still fails the string field, demonstrating that the original boundary remains intact.

An after validator can also be replaced by reusable Annotated metadata when that makes the rule clearer. Prefer the simplest representation that captures the policy. Neither choice establishes uniqueness or ownership of a slug: those require a repository and the current principal. Do not promise database-level guarantees from a field-local check.

Practice solution and acceptance checks

python

import re
from pydantic import BaseModel, ValidationError, field_validator

class Slug(BaseModel):
    value: str

    @field_validator("value", mode="before")
    @classmethod
    def normalize(cls, value):
        return value.strip().lower() if isinstance(value, str) else value

    @field_validator("value", mode="after")
    @classmethod
    def grammar(cls, value):
        if not re.fullmatch(r"[a-z][a-z0-9-]*", value):
            raise ValueError("invalid slug")
        if len(value) > 8:
            raise ValueError("slug too long")
        return value

def inspect_example(payload):
    try:
        return {"value": Slug.model_validate(payload).value}
    except ValidationError as error:
        return {"error": sorted({e["type"] for e in error.errors()})}
Reasoning
The solution checks length after the grammar condition and before returning the string. The acceptance cases show that platform is accepted and developer is rejected. A plus sign still fails the grammar and a number still fails the string field, demonstrating that the original boundary remains intact. An after validator can also be replaced by reusable Annotated metadata when that makes the rule clearer. Prefer the simplest representation that captures the policy. Neither choice establishes uniqueness or ownership of a slug: those require a repository and the current principal. Do not promise database-level guarantees from a field-local check.

Interview reasoning and next step

When reviewing a validator, trace input, type validation, domain checks and return value in that order.

When reviewing a validator, trace input, type validation, domain checks and return value in that order. Ask whether the code accepts only intentional conversions and whether a failing branch produces a useful, non-sensitive error. Then inspect a test that would catch a missing return or an accidentally removed condition.

The next lesson handles relationships between fields. Do not compare independent fields inside a field validator merely because that field happens to be declared last today. A model-level invariant should remain correct when the class is reorganized and should have an explicit example that violates only that relationship.

Optional stretch — after the core task works: Add one independently reasoned boundary case and explain its expected value before running it.

Computer practice: https://applydjinn.com/typorithm/python-api-lab-20261105.zip. Use an isolated Python environment and the pinned dependencies. Run verify.py --track pydantic, then edit the working exercise baseline and run --practice pyd-field-validators. The reference suite rejects meaningful counterexamples and checks values, types and unchanged inputs. All data is owned and all HTTP clients are in-process. No production database, feed, account or paid service is used.

Separate a permitted normalization from a domain check and always return the checked value.