Actionable rules for designing, implementing, and testing secure, maintainable input-validation layers in Python backend APIs.
Your API just crashed because someone sent {"age": "twenty-five"}
instead of {"age": 25}
. Sound familiar? You're spending more time debugging validation edge cases than building features, and your error messages are about as helpful as a chocolate teapot.
Here's what's actually happening in your Python backend:
The result? You're context-switching between debugging validation failures, writing repetitive validation code, and fielding support tickets about cryptic error messages.
These Cursor Rules transform your Python backend into a validation powerhouse using Pydantic, FastAPI, and Django REST Framework patterns that eliminate validation debt before it starts.
Instead of this scattered mess:
def create_user(request_data):
# Validation scattered everywhere
if not request_data.get('email'):
return {"error": "Email required"}
if not re.match(r'^[^@]+@[^@]+\.[^@]+$', request_data['email']):
return {"error": "Invalid email"}
if request_data.get('age') and int(request_data['age']) < 18:
return {"error": "Must be 18+"}
# ... more validation chaos
You get this clean, maintainable approach:
class UserIn(BaseModel):
email: EmailStr
age: Annotated[int, Field(ge=18, le=120)]
name: Annotated[str, Field(min_length=1, max_length=100)]
@app.post("/users")
async def create_user(user: UserIn):
# Validation happens automatically - business logic stays pure
return await user_service.create(user)
Cut validation development time by 60%: Stop writing custom validators for common patterns. The rules provide battle-tested schemas for emails, UUIDs, dates, and complex nested objects.
Eliminate context-switching: Centralized validation means you write it once, use it everywhere. No more hunting through your codebase to understand validation rules.
Reduce debugging time: Consistent, machine-readable error responses mean your frontend team stops asking "what does this error mean?"
Prevent security incidents: Built-in sanitization patterns and input length limits protect against common attack vectors without extra effort.
Before: Writing custom validation for each endpoint
# 45 minutes per endpoint, repeated validation logic
def update_profile(request):
email = request.data.get('email')
if email and not validate_email(email):
return Response({'error': 'Invalid email'}, status=400)
# ... 30 more lines of validation
After: Schema-driven approach
# 5 minutes per endpoint, reusable schemas
class ProfileUpdate(BaseModel):
email: Optional[EmailStr] = None
bio: Annotated[Optional[str], Field(max_length=500)] = None
@api_view(['PATCH'])
def update_profile(request):
try:
data = ProfileUpdate.model_validate(request.data)
return user_service.update_profile(request.user, data)
except ValidationError as e:
return Response(e.errors(), status=422)
Before: Scattered business rules
# Logic buried in views, hard to test
if user_data['role'] == 'admin' and user_data['department'] != 'IT':
raise ValueError("Only IT can be admin")
After: Declarative validation
class UserCreate(BaseModel):
role: Literal['user', 'admin', 'manager']
department: str
@model_validator(mode='after')
def validate_admin_role(self):
if self.role == 'admin' and self.department != 'IT':
raise ValueError('Only IT department can have admin role')
return self
Before: Manual security checks
# Easy to forget security checks
def upload_avatar(request):
file = request.FILES['avatar']
if file.size > 1000000: # Magic number
return JsonResponse({'error': 'Too large'})
# Missing MIME type validation, file extension checks...
After: Comprehensive validation
class AvatarUpload(BaseModel):
file: Annotated[UploadFile, FileValidator(
max_size=1024*1024, # 1MB
allowed_types=['image/jpeg', 'image/png'],
check_mime_type=True
)]
pip install pydantic[email] fastapi python-multipart
Create your schemas directory:
app/
├── schemas/
│ ├── __init__.py
│ ├── user.py
│ └── common.py
├── api/
└── services/
# schemas/user.py
from pydantic import BaseModel, Field, EmailStr
from typing import Annotated, Optional
from datetime import datetime
class UserCreate(BaseModel):
email: EmailStr
name: Annotated[str, Field(min_length=1, max_length=100)]
age: Annotated[int, Field(ge=18, le=120)]
model_config = ConfigDict(str_strip_whitespace=True)
class UserOut(BaseModel):
id: UUID4
email: EmailStr
name: str
created_at: datetime
FastAPI:
from fastapi import FastAPI, HTTPException
from pydantic import ValidationError
@app.post("/users", response_model=UserOut)
async def create_user(user: UserCreate):
try:
return await user_service.create(user)
except ValidationError as e:
raise HTTPException(status_code=422, detail=e.errors())
Django REST Framework:
from rest_framework.decorators import api_view
from rest_framework.response import Response
@api_view(['POST'])
def create_user(request):
try:
user_data = UserCreate.model_validate(request.data)
result = user_service.create(user_data)
return Response(UserOut.model_dump(result))
except ValidationError as e:
return Response(e.errors(), status=422)
import pytest
from pydantic import ValidationError
class TestUserValidation:
def test_valid_user_creation(self):
data = {"email": "[email protected]", "name": "John", "age": 25}
user = UserCreate.model_validate(data)
assert user.email == "[email protected]"
@pytest.mark.parametrize("invalid_data,expected_error", [
({"email": "invalid", "name": "John", "age": 25}, "email"),
({"email": "[email protected]", "name": "", "age": 25}, "name"),
({"email": "[email protected]", "name": "John", "age": 15}, "age"),
])
def test_validation_errors(self, invalid_data, expected_error):
with pytest.raises(ValidationError) as exc_info:
UserCreate.model_validate(invalid_data)
assert expected_error in str(exc_info.value)
Development Velocity: Teams report 40-60% reduction in validation-related development time. You write validation logic once and reuse it across endpoints.
Bug Reduction: Centralized validation eliminates the "I forgot to validate that field" bugs that plague ad-hoc validation approaches.
Frontend Developer Happiness: Consistent error message formats mean your frontend team can build robust error handling once instead of handling edge cases for every endpoint.
Security Posture: Built-in input sanitization and length limits prevent common attack vectors without requiring security expertise for every validation rule.
Code Review Efficiency: Validation logic is declarative and co-located, making reviews faster and more thorough.
The difference is night and day. Instead of scattered validation logic that breaks when requirements change, you have a centralized, type-safe validation layer that grows with your application.
Your future self will thank you when you need to add a new validation rule and it takes 30 seconds instead of 30 minutes hunting through your codebase.
You are an expert in Python 3.11+, FastAPI, Django Rest Framework, Pydantic v2, Cerberus.
Key Principles
- Validate every external boundary (HTTP body, query, headers, env-vars, CLI, file uploads).
- Prefer **early, centralized, schema-driven validation** using Pydantic/Cerberus; keep business logic pure.
- Apply allow-listing (positive validation) instead of deny-listing.
- Fail fast: raise explicit `ValidationError` as soon as the first rule is broken.
- Log validation failures with request correlation-id; never log secrets or PII.
- Treat validation, sanitization, and normalization as separate steps; never mutate the original payload.
Python
- Use static typing everywhere (`from typing import Annotated, Literal`); `mypy --strict` must pass.
- Define payload contracts with `class UserIn(BaseModel): ...`; never accept `dict` untyped.
- Stick to `snake_case` for variables/functions, `PascalCase` for classes.
- Co-locate validation schema next to DTOs in `schemas/` and re-export from `__init__.py`.
- Always raise `ValueError` or subclassed `ValidationError`; never return magic strings.
- Use f-strings in error messages: `f"age must be ≥ 18 (got {age})"`.
Error Handling and Validation
- Pattern:
```python
def create_user(payload: dict) -> UserOut:
try:
dto = UserIn.model_validate(payload, strict=True)
except ValidationError as err:
logger.info("validation_failed", extra={"errors": err.errors()})
raise HTTPException(status_code=422, detail=err.errors())
return _create_user(dto)
```
- Group rules: data-type, range, regex/format, cross-field consistency, DB uniqueness.
- Provide machine-readable error codes: `{ "loc": ["body","email"], "msg": "already_taken", "code": "email_unique" }`.
- Always sanitize secondary danger zones (SQL, shell, HTML) even after validation (parameterized queries, ORM, Jinja auto-escape).
FastAPI
- Use Pydantic models in function signature:
`async def register_user(user: Annotated[UserIn, Body(embed=True)]): ...`
- Add reusable dependency for pagination validation:
```python
Page = Annotated[int, Query(ge=1, le=500)]
Size = Annotated[int, Query(ge=1, le=100)]
```
- Convert path params to proper types (`UUID`, `datetime`); FastAPI will reject malformed strings automatically.
- Implement custom validators with `@field_validator` and register root validators for cross-field checks.
- Surface 422 errors via standard Problem Details JSON.
Django / DRF
- Use `Serializer` classes for HTTP boundaries; model `clean()` for internal invariants.
- Combine built-in validators (`EmailValidator`, `RegexValidator`) with custom `validate_<field>()` methods.
- Enforce atomic cross-field validation in `validate(self, attrs)`.
- For query params, use DRF’s `filters` or `django-filter` plus strict typing layer (`dataclasses-jsonschema`).
Additional Sections
Testing
- Unit test each validator: happy path + boundary + invalid + malicious (XSS, SQLi).
- Use `pytest.param` with `id="<rule>"` to enumerate invalid cases.
- Build golden JSON fixtures for error messages to guard against silent format drift.
Performance
- Turn on Pydantic compiled mode (`model_config = ConfigDict(arbitrary_types_allowed=True, ser_json_typed=True)`).
- Batch-validate large CSV uploads with generator comprehension: `for chunk in read_chunks(file): yield UserIn.model_validate(chunk)`.
- Cache expensive regexes (`re.compile`) at module import.
Security
- Reject payloads > 1 MB before parsing to mitigate DoS.
- Use `bleach.clean()` only when HTML is absolutely required; otherwise strip tags.
- Explicitly limit list lengths (`list[Item, min_items=1, max_items=1000]`).
- Validate file MIME-type with server-side probe (e.g., `python-magic`) in addition to client headers.
Directory Convention
```
project_root/
├─ app/
│ ├─ api/
│ │ └─ v1/
│ │ └─ users.py # FastAPI routers
│ ├─ schemas/ # Pydantic models (validation only)
│ ├─ models/ # SQLAlchemy models (DB only)
│ ├─ services/ # Business logic, receives validated DTOs
│ └─ errors.py # Domain-level exceptions
└─ tests/
└─ validation/
```
Common Pitfalls (and fixes)
- Nullable fields accepted as empty string → add `strict=True` or `pattern=r"^$"` check.
- Timezone-naïve datetimes → enforce `tzinfo` with `field_validator("ts", mode="after")`.
- Accepting `int` for IDs that should be UUID4 → switch to `uuid.UUID` type.
Reference Cheat-Sheet
- `Regex`: use `re.fullmatch` not `match`.
- Numbers: `conint(ge=0, le=100)`, `condecimal(max_digits=9, decimal_places=2)`.
- Collections: `list[str, min_length=1, max_length=20]`, `set[Literal['A','B','C']]`.
- Dates: `from datetime import date; field(type=date)`, add `before_today` validator.
Follow these rules to build resilient, transparent, and maintainable input-validation layers for Python back-end APIs.