DEV Community

Sanskar Kharya
Sanskar Kharya

Posted on

Type-Safe Multi-Tenant Request Validation in FastAPI with Pydantic v2

Multi-tenant APIs often need to apply different validation rules depending on which tenant is making a request.

For example, imagine an order API where:

  • acme can create orders with up to 100 items.
  • globex can create orders with up to 10 items.

A straightforward implementation might put tenant-specific checks directly inside the endpoint:

if tenant == "acme":
    ...
elif tenant == "globex":
    ...
Enter fullscreen mode Exit fullscreen mode

That works initially, but as the number of tenants and rules grows, the endpoint starts becoming responsible for both handling the request and understanding tenant-specific business rules.

This tutorial demonstrates a different approach using FastAPI dependencies and Pydantic v2 validation context.

The goal is to resolve the tenant before validating the request and pass that tenant configuration into Pydantic, while keeping the route handler free of tenant-specific validation logic.

What we're building

The request will look like this:

POST /orders
X-Tenant-ID: globex
Content-Type: application/json
Enter fullscreen mode Exit fullscreen mode
{
  "product_id": "prod_123",
  "quantity": 5
}
Enter fullscreen mode Exit fullscreen mode

The application will:

  1. Resolve the tenant from X-Tenant-ID.
  2. Reject unknown or inactive tenants.
  3. Validate the request using Pydantic.
  4. Apply the tenant's validation rules using Pydantic's validation context.
  5. Pass a fully validated object to the endpoint.

The complete implementation is available in the fastapi-multitenant-validation GitHub repository.

Project structure

fastapi-multitenant-validation/
├── app/
│   ├── config.py
│   ├── dependencies.py
│   ├── main.py
│   └── models.py
├── tests/
│   ├── conftest.py
│   └── test_orders.py
├── pyproject.toml
└── README.md
Enter fullscreen mode Exit fullscreen mode

The example intentionally keeps tenant configuration in memory. There is no database or external service because the goal is to demonstrate the validation architecture rather than build a complete multi-tenant platform.

Defining tenant configuration

First, we need a representation of a tenant.

from pydantic import BaseModel, Field


class TenantConfig(BaseModel):
    tenant_id: str = Field(..., description="Unique tenant identifier")
    name: str = Field(..., description="Human-readable tenant name")
    max_order_quantity: int = Field(
        ...,
        description="Maximum quantity allowed in a single order",
    )
    active: bool = Field(
        ...,
        description="Whether the tenant is currently active",
    )
Enter fullscreen mode Exit fullscreen mode

For the example, we use a deterministic in-memory registry:

TENANTS: dict[str, TenantConfig] = {
    "acme": TenantConfig(
        tenant_id="acme",
        name="Acme Corporation",
        max_order_quantity=100,
        active=True,
    ),
    "globex": TenantConfig(
        tenant_id="globex",
        name="Globex Inc",
        max_order_quantity=10,
        active=True,
    ),
    "inactive_co": TenantConfig(
        tenant_id="inactive_co",
        name="Inactive Co",
        max_order_quantity=50,
        active=False,
    ),
}
Enter fullscreen mode Exit fullscreen mode

The important part is that the tenant configuration contains information that can affect validation.

Defining the request model

The request-level constraints belong in our Pydantic model:

from pydantic import BaseModel, Field, ValidationInfo, model_validator


class OrderCreate(BaseModel):
    product_id: str = Field(..., min_length=1)
    quantity: int = Field(..., gt=0)

    @model_validator(mode="after")
    def validate_tenant_limits(
        self,
        info: ValidationInfo,
    ) -> "OrderCreate":
        if info.context and "tenant" in info.context:
            tenant: TenantConfig = info.context["tenant"]

            if self.quantity > tenant.max_order_quantity:
                raise ValueError(
                    f"Quantity {self.quantity} exceeds maximum allowed "
                    f"limit ({tenant.max_order_quantity}) for tenant "
                    f"'{tenant.tenant_id}'."
                )

        return self
Enter fullscreen mode Exit fullscreen mode

These rules represent two different kinds of validation.

General request validation can be performed from the body alone:

product_id → must not be empty
quantity   → must be greater than zero
Enter fullscreen mode Exit fullscreen mode

Tenant-specific validation needs information outside the body:

quantity → must not exceed tenant.max_order_quantity
Enter fullscreen mode Exit fullscreen mode

Pydantic v2's validation context gives us a way to provide that external information during validation.

Resolving the tenant with a FastAPI dependency

Next, we resolve the tenant from the request header.

The repository uses FastAPI's required-header behavior directly:

from fastapi import Header, HTTPException


def get_tenant(
    x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
) -> TenantConfig:
    tenant = TENANTS.get(x_tenant_id)

    if tenant is None:
        raise HTTPException(
            status_code=404,
            detail=f"Tenant '{x_tenant_id}' not found",
        )

    if not tenant.active:
        raise HTTPException(
            status_code=403,
            detail=f"Tenant '{x_tenant_id}' is inactive",
        )

    return tenant
Enter fullscreen mode Exit fullscreen mode

Because the header is required, FastAPI itself handles a missing X-Tenant-ID header and returns a 422 Unprocessable Entity response.

For an unknown tenant, the dependency returns 404.

For an inactive tenant, it returns 403.

Most importantly, downstream dependencies now receive a strongly typed TenantConfig.

Connecting tenant resolution to Pydantic validation

This is where the two pieces come together.

We intentionally receive the request body as a raw dictionary:

payload: dict[str, Any] = Body(...)
Enter fullscreen mode Exit fullscreen mode

That allows the tenant dependency to run before we explicitly invoke Pydantic validation.

The important part of the dependency is:

from pydantic import ValidationError
from fastapi import Body, Depends
from fastapi.exceptions import RequestValidationError


def get_validated_order(
    payload: dict[str, Any] = Body(...),
    tenant: TenantConfig = Depends(get_tenant),
) -> TenantOrderContext:
    try:
        order = OrderCreate.model_validate(
            payload,
            context={"tenant": tenant},
        )
    except ValidationError as exc:
        raise RequestValidationError(
            exc.errors(include_url=False)
        ) from exc

    return TenantOrderContext(
        tenant=tenant,
        order=order,
    )
Enter fullscreen mode Exit fullscreen mode

The key line is:

OrderCreate.model_validate(
    payload,
    context={"tenant": tenant},
)
Enter fullscreen mode Exit fullscreen mode

The tenant configuration is now available inside Pydantic's ValidationInfo.context.

Why catch ValidationError?

Normally, FastAPI automatically handles validation errors generated while processing request parameters.

Here, however, we are deliberately calling Pydantic ourselves inside a dependency.

That means we need to catch Pydantic's ValidationError and convert it into FastAPI's RequestValidationError. Otherwise, a validation failure could escape the normal request-validation handling and become a 500 Internal Server Error.

By converting it to RequestValidationError, the API consistently returns 422 Unprocessable Entity for both ordinary Pydantic validation failures and tenant-specific validation failures.

Keeping the endpoint clean

The endpoint receives a strongly typed TenantOrderContext:

@app.post(
    "/orders",
    response_model=OrderResponse,
    status_code=status.HTTP_201_CREATED,
)
def create_order(
    ctx: TenantOrderContext = Depends(get_validated_order),
) -> OrderResponse:
    return OrderResponse(
        order_id="ord_123456",
        tenant_id=ctx.tenant.tenant_id,
        product_id=ctx.order.product_id,
        quantity=ctx.order.quantity,
        status="accepted",
    )
Enter fullscreen mode Exit fullscreen mode

There is no:

if tenant == "acme":
Enter fullscreen mode Exit fullscreen mode

and no:

if tenant == "globex":
Enter fullscreen mode Exit fullscreen mode

The endpoint receives a context whose tenant is already known to be active and whose order has already passed both baseline and tenant-specific validation.

That separation is the main architectural point of the example.

Testing the behavior

The repository includes eight tests covering successful requests and failure cases.

For example, Globex allows a maximum quantity of 10:

response = client.post(
    "/orders",
    headers={"X-Tenant-ID": "globex"},
    json={
        "product_id": "prod_123",
        "quantity": 5,
    },
)

assert response.status_code == 201
Enter fullscreen mode Exit fullscreen mode

But a quantity of 15 should fail:

response = client.post(
    "/orders",
    headers={"X-Tenant-ID": "globex"},
    json={
        "product_id": "prod_123",
        "quantity": 15,
    },
)

assert response.status_code == 422
Enter fullscreen mode Exit fullscreen mode

The test suite also covers:

  • invalid quantities
  • missing tenant headers
  • unknown tenants
  • inactive tenants
  • non-object JSON payloads

The complete suite passes successfully.

An important trade-off

There is a reason we don't simply declare:

order: OrderCreate
Enter fullscreen mode Exit fullscreen mode

as the request body and let FastAPI handle everything automatically.

For this particular example, we need tenant information before performing tenant-aware Pydantic validation.

Receiving the raw body and explicitly calling:

OrderCreate.model_validate(
    payload,
    context={"tenant": tenant},
)
Enter fullscreen mode Exit fullscreen mode

gives us that control.

The trade-off is that FastAPI's automatically generated OpenAPI documentation does not automatically expose the complete OrderCreate request schema in the same way that a normal Pydantic request-body parameter would.

This is intentional in the example. In a production system, you would need to decide whether this validation architecture or richer automatically generated API documentation is more important, or investigate an integration that provides both.

Conclusion

The main idea isn't that every FastAPI application should manually validate request bodies.

The useful pattern is separating responsibilities:

Request
   ↓
Tenant dependency
   ↓
TenantConfig
   ↓
Pydantic validation context
   ↓
Validated TenantOrderContext
   ↓
Clean endpoint
Enter fullscreen mode Exit fullscreen mode

FastAPI dependencies handle request context, while Pydantic handles validation.

This becomes particularly useful when validation rules depend on information that isn't contained in the request body itself.

The complete, tested implementation is available in the fastapi-multitenant-validation GitHub repository.

Type-Safe Multi-Tenant Request Validation with FastAPI & Pydantic v2

A minimal, production-inspired educational Python example demonstrating type-safe multi-tenant request validation using FastAPI dependencies and Pydantic v2 validation context (info.context).


Purpose

Multi-tenant applications often need to validate incoming API payloads against tenant-specific rules (such as max order quantities, allowed product categories, or rate limits).

A common anti-pattern is placing if tenant == "acme": ... conditional branching inside route handlers. This pollutes endpoint logic and duplicates validation code.

This project demonstrates how to move tenant resolution and tenant-specific schema validation into FastAPI dependencies before the endpoint receives the request. The endpoint stays clean and receives a strongly-typed TenantOrderContext.


Request Flow & Architecture

Client
  │ (POST /orders, Header: X-Tenant-ID, JSON Body)
  ▼
1. get_tenant Dependency
  │ Reads X-Tenant-ID header & resolves TenantConfig
  │ Rejects missing header (422), unknown tenant (404), or inactive tenant (403)
  ▼
2. get_validated_order Dependency
  │ Receives

Top comments (0)