Problem
You're building a FastAPI endpoint that needs to filter data by multiple criteria. Your frontend sends filter parameters as comma-separated strings (because that's how query params work), but you want proper typing with enums and optional lists on the backend.
Classic scenario:
GET /api/products?status=ACTIVE,PENDING&category=ELECTRONICS,BOOKS
But Pydantic expects lists, and you want enum validation. Plus everything should be optional.
The Challenge
Standard Pydantic approach fails here:
class FilterParams(BaseModel):
status: Optional[list[ProductStatus]] = None# Won't work with "ACTIVE,PENDING"
category: Optional[list[str]] = None# Won't work with "ELECTRONICS,BOOKS"
The client sends "ACTIVE,PENDING"
as a single string, but you need [ProductStatus.ACTIVE, ProductStatus.PENDING]
.
Solution
Use BeforeValidator
with a custom parser that handles both string-to-list conversion and enum casting:
from typing import Annotated, Optional
from pydantic import BaseModel, BeforeValidator
from enum import StrEnum
class ProductStatus(StrEnum):
ACTIVE = "ACTIVE"
PENDING = "PENDING"
INACTIVE = "INACTIVE"
def parse_comma_separated_list(enum_type: Optional[type[StrEnum]] = None):
def parser(v: list[str]):
# FastAPI wraps single values in lists
value = v[0] if v else None
if value is None:
return None
items = value.split(',')
# Cast to enum if provided
if enum_type:
return [enum_type(item) for item in items]
return items
return parser
class ProductFilterParams(BaseModel):
status: Annotated[
Optional[list[ProductStatus]],
BeforeValidator(parse_comma_separated_list(ProductStatus))
] = None
category: Annotated[
Optional[list[str]],
BeforeValidator(parse_comma_separated_list())
] = None
@router.get('/products')
async def list_products(
filters: Annotated[ProductFilterParams, Query()],
):
# filters.status is now properly typed as list[ProductStatus] or None# filters.category is list[str] or None
return await get_products(
status=filters.status,
category=filters.category
)
How It Works
- FastAPI Query Parsing: FastAPI automatically wraps query param values in lists
- BeforeValidator: Runs before Pydantic's standard validation
- String Splitting: Takes the first item from the list (the comma-separated string) and splits it
- Enum Casting: If enum type provided, casts each item to the enum
- Type Safety: Final result is properly typed for your business logic
My Experience
This hit me when refactoring an existing API. The frontend was using DiceUI filters that send multiple values as comma-separated strings.
First attempt was parsing directly in each model - messy and not reusable. Every endpoint would need its own parsing logic.
After about 2 hours of digging through Pydantic docs, I found BeforeValidator
. Perfect fit - handles the transformation before validation, keeps models clean, and works everywhere.
The beauty is writing minimal code that solves the problem once and reuses everywhere.
Why This Approach?
- Type Safety: Full enum validation and IDE support
- Optional by Design: Handles missing params gracefully
- Reusable: Works with any enum or plain strings
- Clean API: Business logic gets properly typed data
The BeforeValidator
pattern is perfect for these "format transformation + validation" scenarios.
That's it! Clean, reusable, and type-safe query param handling. If this helped you out, drop a like or share your own Pydantic tricks in the comments!
Top comments (0)