Validating at the Boundary

Convert and check incoming data where it arrives, fail with a message that names the field, and pass typed values inward.

Syntaxclean, errors = validate(payload) # or: raise ValidationError("field: message")

A dict decoded from JSON is not your data. It is whatever the caller sent, with keys you hoped for and types you did not check. The cost of letting it travel inward is that the failure surfaces three functions deep, as TypeError: unsupported operand type(s) for *: 'str' and 'int', with no indication of which request field was wrong.

One place, one pass

Put the conversion at the boundary: the request handler, the CSV row reader, the queue consumer. One function takes the untrusted mapping and returns your own type — a dataclass, or at minimum a dict whose every value has already been converted. From there inward, types are a contract rather than a hope. Note that type hints are not enforced at runtime, so the annotation on a dataclass field documents the intent; the converter is what makes it true.

Failing usefully

Two rules. First, name the field: "total: not a valid Decimal" beats "invalid input". Second, collect every error rather than raising on the first, so a caller fixes one round trip instead of five — that is what an HTTP 422 body is for. Convert with the type's own constructor (int, Decimal, datetime.fromisoformat) and catch ValueError, TypeError and decimal.InvalidOperation around it.

Missing is not the same as wrong

A key absent and a key present with a bad value are different failures. payload.get("qty", 0) is the shorthand for a try/except KeyError that returns a default — convenient, and it turns a missing required field into a plausible-looking zero. Decide per field whether a default is correct, and reject the rest by name.

A schema library does the same job declaratively — in pydantic v2, Model.model_validate(payload) raising ValidationError — but the discipline, not the library, is what fixes the bug.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Turn a decoded JSON request body into a dataclass, rejecting bad fields with a 422 listing each one.
  • Convert CSV string columns to int, Decimal and datetime once, as the rows are read.
  • Reject a timestamp that arrives without a timezone offset instead of guessing one.
  • Replace a scatter of isinstance checks spread through business logic with one parse at the entry point.

More examples

A converter table and a field-to-message result

Returning errors rather than raising suits a web handler, where the error dict maps straight onto a JSON response body keyed by field name. The converter table keeps the rules in one readable place; add a tuple of (converter, required, default) per field as the requirements grow.

Example · python
from decimal import Decimal, InvalidOperation

CONVERTERS = {"id": int, "email": str, "total": Decimal}

def validate(payload: dict) -> tuple[dict, dict]:
    """Return (clean, errors). One pass, every field reported, nothing raised -
    the caller decides whether that is a 422 body or a log line."""
    clean, errors = {}, {}
    for field, convert in CONVERTERS.items():
        if field not in payload:
            errors[field] = "required"
            continue
        try:
            clean[field] = convert(payload[field])
        except (TypeError, ValueError, InvalidOperation):
            errors[field] = f"not a valid {convert.__name__}"
    return clean, errors

clean, errors = validate({"id": "x", "email": "[email protected]"})
print(errors)   # {'id': 'not a valid int', 'total': 'required'}

Invariants that span fields: __post_init__

__post_init__ runs after the generated __init__ assigns the fields, which makes it the right place for cross-field rules. Keep per-field type conversion out of it: by the time the dataclass is constructed you want the types already correct, so the only thing left to check is the relationship between them.

Example · python
from dataclasses import dataclass
from decimal import Decimal

@dataclass(frozen=True)
class Order:
    id: int
    total: Decimal
    refunded: Decimal = Decimal("0.00")

    def __post_init__(self):
        # Checks about the record as a whole, not about one field's type.
        if self.total < 0:
            raise ValueError(f"total: must not be negative, got {self.total}")
        if self.refunded > self.total:
            raise ValueError("refunded: must not exceed total")

# Type hints are NOT checked at runtime: Order(id="7", total=Decimal("1"))
# constructs happily and the str travels on. The converter at the boundary is
# what makes the annotation true.
print(Order(7, Decimal("19.99")))

The boundary is the first line of the handler

The pattern transfers unchanged to a CSV row reader or a queue consumer: parse, reject, then work with your own type. The payoff is the second print - a bad request produces a complete list of what was wrong, at the edge, instead of a 500 from somewhere in the middle of the call stack.

Example · python
# handlers/orders.py
from decimal import Decimal

def create_order(payload: dict):
    clean, errors = validate(payload)        # the function from the example above
    if errors:
        return 422, {"errors": errors}       # names every field that failed

    order = Order(id=clean["id"], total=clean["total"])
    # Past this line nothing is a str that should be an int, and no function
    # downstream has to ask whether the data was checked.
    return 201, {"id": order.id}

print(create_order({"id": "7", "email": "[email protected]", "total": "19.99"}))
print(create_order({"email": "[email protected]"}))

Discussion

  • Be the first to comment on this lesson.