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:
-
acmecan create orders with up to 100 items. -
globexcan 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":
...
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
{
"product_id": "prod_123",
"quantity": 5
}
The application will:
- Resolve the tenant from
X-Tenant-ID. - Reject unknown or inactive tenants.
- Validate the request using Pydantic.
- Apply the tenant's validation rules using Pydantic's validation context.
- 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
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",
)
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,
),
}
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
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
Tenant-specific validation needs information outside the body:
quantity → must not exceed tenant.max_order_quantity
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
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(...)
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,
)
The key line is:
OrderCreate.model_validate(
payload,
context={"tenant": tenant},
)
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",
)
There is no:
if tenant == "acme":
and no:
if tenant == "globex":
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
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
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
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},
)
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
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)