Python: Type Hints, Pydantic, and Reliable Request Validation

Introduction

Python’s flexibility makes it productive, but that flexibility can become a liability when an application receives unexpected input. A function may work perfectly with valid data and then fail when a request contains a missing field, an invalid number, or a value of the wrong type.

Type hints and Pydantic address different parts of this problem. Type hints communicate the intended structure of data and enable static analysis, while Pydantic validates data at runtime and can convert supported input values into the required Python types.

This article explains how to combine both approaches to build more reliable Python APIs.

1. Add type hints to application code

Consider a simple function that calculates the total price of an order:

def calculate_total(price, quantity):    return price * quantity

Nothing prevents a caller from passing a string as the quantity. The resulting behavior might be incorrect or fail in an unexpected place.

Type hints make the intended contract explicit:

from decimal import Decimaldef calculate_total(    price: Decimal,    quantity: int,) -> Decimal:    return price * quantity

A type checker such as mypy or Pyright can identify many incorrect calls before the application runs.

However, Python does not automatically enforce ordinary type hints at runtime. This call can still happen:

calculate_total("10.00", "three")

Type hints improve development-time safety, but external input still needs runtime validation.

2. Validate external data with Pydantic

Pydantic models describe the shape of incoming data and validate it when a model is created.

Install Pydantic with:

pip install pydantic

Define a request model:

from decimal import Decimalfrom pydantic import BaseModel, Fieldclass CreateOrderRequest(BaseModel):    product_id: int = Field(gt=0)    quantity: int = Field(gt=0)    unit_price: Decimal = Field(ge=0)

Now the application can validate input before processing the order:

payload = {    "product_id": 42,    "quantity": 3,    "unit_price": "19.95",}request = CreateOrderRequest.model_validate(payload)print(request.quantity)print(request.unit_price)

Pydantic validates the fields and produces a structured object. The constraints also express business expectations: product IDs and quantities must be positive, while the unit price cannot be negative.

Pydantic supports conversion for many compatible inputs, so validation is not necessarily limited to values already having the exact Python type.

3. Use stricter validation when coercion is undesirable

Automatic conversion is useful, but it is not always the desired behavior. For example, an application may want to reject a string where an actual integer is required.

Pydantic supports strict validation:

from pydantic import BaseModel, ConfigDictclass StrictRequest(BaseModel):    model_config = ConfigDict(strict=True)    quantity: int

The exact validation behavior depends on the field type and input source, but strict mode reduces implicit conversions.

Choose between normal and strict validation based on the API contract rather than enabling strictness without considering compatibility.

4. Validate business rules separately

Not every rule belongs in a request schema. Pydantic is good at checking field types, ranges, required values, and relationships between fields. Rules that depend on database state or other services usually belong in an application or domain layer.

For example, this schema can validate the request shape:

from pydantic import BaseModel, Fieldclass TransferRequest(BaseModel):    source_account_id: int    destination_account_id: int    amount: float = Field(gt=0)

But it cannot establish that the source account exists, belongs to the current user, or has sufficient funds. Those checks require application logic and, for a transfer, appropriate transactional safeguards.

Also consider using Decimal rather than float for financial calculations to avoid binary floating-point surprises.

5. Handle validation errors consistently

In an API, invalid input should produce a predictable response rather than an unhandled exception.

FastAPI integrates Pydantic models into request parsing and validation:

from fastapi import FastAPIfrom pydantic import BaseModel, Fieldapp = FastAPI()class CreateItemRequest(BaseModel):    name: str = Field(min_length=1, max_length=100)    quantity: int = Field(gt=0)@app.post("/items")def create_item(request: CreateItemRequest):    return {        "name": request.name,        "quantity": request.quantity,    }

FastAPI automatically validates the request body and returns a structured client error for invalid input. The framework also uses the model to describe the request in the generated OpenAPI documentation.

Do not expose stack traces, database details, or secrets in validation responses. Keep error formats consistent and document them for API consumers.

6. Avoid turning every object into a validation model

Pydantic is useful at boundaries: HTTP requests, configuration, external API responses, and imported data. It does not need to replace every internal Python class.

A useful separation is:

  • Request models: Validate incoming data.
  • Domain models: Represent application concepts and enforce domain rules.
  • Persistence models: Represent database storage.
  • Response models: Define what the API exposes to clients.

These models may overlap, but they serve different purposes. Keeping them separate can prevent internal database changes from unintentionally changing public API contracts.

Conclusion

Type hints make Python code easier to understand and help static analysis catch mistakes, while Pydantic validates data at runtime. Use both deliberately: validate untrusted input at application boundaries, enforce business rules in the appropriate layer, and return predictable errors.

The result is not a statically typed Python application, but a codebase with clearer contracts and fewer avoidable runtime surprises.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *