Skip to main content

FastAPI Field Validation

When developing APIs, validating incoming data is crucial to ensure that your application processes correct and well-structured information. FastAPI, coupled with Pydantic, offers powerful validation capabilities that help maintain data integrity while reducing the amount of validation code you need to write.

Introduction to Field Validation

Field validation in FastAPI is powered by Pydantic models. These models not only define the structure of your data but also enforce constraints on the values that fields can accept. This validation happens automatically when data is received, before it reaches your route functions, giving you clean, validated data to work with.

Let's explore how field validation works in FastAPI and how you can leverage it to build robust APIs.

Basic Field Validation

Using Built-in Validation

Pydantic provides several built-in validators that you can use to enforce constraints on your data:

python
from fastapi import FastAPI
from pydantic import BaseModel, Field, EmailStr

app = FastAPI()

class User(BaseModel):
username: str = Field(..., min_length=3, max_length=50)
email: EmailStr
age: int = Field(..., gt=0, lt=120)
is_active: bool = True

@app.post("/users/")
async def create_user(user: User):
return {"user": user, "message": "User created successfully"}

In this example:

  • username must be between 3 and 50 characters long
  • email must be a valid email address (requires email-validator package)
  • age must be greater than 0 and less than 120
  • is_active defaults to True if not provided

Field Options

The Field function allows you to specify various constraints:

ConstraintDescriptionExample
min_lengthMinimum length for stringsField(..., min_length=5)
max_lengthMaximum length for stringsField(..., max_length=50)
gtGreater thanField(..., gt=0)
geGreater than or equalField(..., ge=18)
ltLess thanField(..., lt=100)
leLess than or equalField(..., le=99)
regexRegular expression patternField(..., regex="^[a-zA-Z]*$")

Testing Basic Validation

Let's see what happens when we send invalid data to our API:

If we send this JSON:

json
{
"username": "jo",
"email": "not-an-email",
"age": -5
}

The API would respond with a validation error:

json
{
"detail": [
{
"loc": ["body", "username"],
"msg": "ensure this value has at least 3 characters",
"type": "value_error.any_str.min_length",
"ctx": {"limit_value": 3}
},
{
"loc": ["body", "email"],
"msg": "value is not a valid email address",
"type": "value_error.email"
},
{
"loc": ["body", "age"],
"msg": "ensure this value is greater than 0",
"type": "value_error.number.not_gt",
"ctx": {"limit_value": 0}
}
]
}

Custom Validators

Sometimes built-in validators aren't enough. For custom validation logic, Pydantic provides the validator decorator:

python
from fastapi import FastAPI
from pydantic import BaseModel, validator

app = FastAPI()

class Product(BaseModel):
name: str
price: float
quantity: int

@validator('name')
def name_must_be_capitalized(cls, v):
if not v[0].isupper():
raise ValueError('Product name must start with a capital letter')
return v

@validator('price')
def price_must_be_positive(cls, v):
if v <= 0:
raise ValueError('Price must be positive')
return v

@validator('quantity')
def quantity_must_be_at_least_one(cls, v):
if v < 1:
raise ValueError('Quantity must be at least 1')
return v

@app.post("/products/")
async def create_product(product: Product):
return {"product": product, "message": "Product created successfully"}

Here we've added custom validations to ensure:

  • Product name starts with a capital letter
  • Price is positive
  • Quantity is at least 1

Cross-field Validation

You can also perform validation that depends on multiple fields:

python
from fastapi import FastAPI
from pydantic import BaseModel, validator

app = FastAPI()

class Discount(BaseModel):
original_price: float
discounted_price: float

@validator('discounted_price')
def discount_must_be_less_than_original(cls, v, values):
if 'original_price' in values and v >= values['original_price']:
raise ValueError('Discounted price must be less than original price')
return v

@app.post("/discounts/")
async def create_discount(discount: Discount):
# Calculate discount percentage
discount_percentage = ((discount.original_price - discount.discounted_price) /
discount.original_price) * 100

return {
"discount": discount,
"discount_percentage": f"{discount_percentage:.2f}%",
"message": "Discount created successfully"
}

In this example, we validate that the discounted price is less than the original price.

Advanced Validation Techniques

Root Validators

When you need to validate multiple fields together or after all fields have been validated:

python
from fastapi import FastAPI
from pydantic import BaseModel, root_validator

app = FastAPI()

class Order(BaseModel):
items: int
price_per_item: float
total_price: float

@root_validator
def check_total_price(cls, values):
items = values.get('items')
price_per_item = values.get('price_per_item')
total_price = values.get('total_price')

if items and price_per_item and total_price:
expected_total = items * price_per_item
if abs(expected_total - total_price) > 0.01: # Allow small floating point differences
raise ValueError(
f'Total price {total_price} does not match items * price_per_item = {expected_total}'
)
return values

@app.post("/orders/")
async def create_order(order: Order):
return {"order": order, "message": "Order created successfully"}

Field Aliases

Sometimes your API needs to use a different field name than what's in the JSON:

python
from fastapi import FastAPI
from pydantic import BaseModel, Field

app = FastAPI()

class User(BaseModel):
user_id: int = Field(..., alias="userId")
full_name: str = Field(..., alias="fullName")
email: str

class Config:
allow_population_by_field_name = True

@app.post("/users/")
async def create_user(user: User):
return {"user": user, "message": "User created successfully"}

This allows clients to send data with camelCase field names (userId, fullName) while your Python code uses snake_case (user_id, full_name).

Constrained Types

For frequently used validation patterns, Pydantic provides constrained types:

python
from fastapi import FastAPI
from pydantic import BaseModel, constr, conint, confloat

app = FastAPI()

class Item(BaseModel):
name: constr(min_length=3, max_length=50)
description: constr(max_length=1000)
price: confloat(gt=0)
quantity: conint(ge=0)
tags: list[constr(min_length=2, regex=r'^[a-zA-Z0-9]+$')]

@app.post("/items/")
async def create_item(item: Item):
return {"item": item, "message": "Item created successfully"}

Custom Error Messages

You can customize error messages for better user experience:

python
from fastapi import FastAPI
from pydantic import BaseModel, Field, validator

app = FastAPI()

class SignupData(BaseModel):
username: str = Field(
...,
min_length=5,
max_length=20,
description="Username must be between 5 and 20 characters"
)
password: str

@validator('password')
def password_strength(cls, v):
if len(v) < 8:
raise ValueError('Password must be at least 8 characters')
if not any(char.isdigit() for char in v):
raise ValueError('Password must contain at least one digit')
if not any(char.isupper() for char in v):
raise ValueError('Password must contain at least one uppercase letter')
return v

@app.post("/signup/")
async def signup(data: SignupData):
# In a real app, you would hash the password before storing it
return {"username": data.username, "message": "Signup successful"}

Real-World Application: User Registration Form

Let's build a more comprehensive example that resembles a real-world user registration API:

python
from typing import Optional
from datetime import date, datetime
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, EmailStr, Field, validator, root_validator
import re

app = FastAPI()

class UserRegistration(BaseModel):
username: str = Field(..., min_length=4, max_length=20)
email: EmailStr
password: str
confirm_password: str
birth_date: date
bio: Optional[str] = Field(default=None, max_length=500)
terms_accepted: bool = Field(...)

@validator('username')
def username_alphanumeric(cls, v):
if not re.match(r'^[a-zA-Z0-9_]+$', v):
raise ValueError('Username must be alphanumeric with underscores only')
return v

@validator('password')
def password_strength(cls, v):
if len(v) < 8:
raise ValueError('Password must be at least 8 characters long')
if not re.search(r'[A-Z]', v):
raise ValueError('Password must contain at least one uppercase letter')
if not re.search(r'[a-z]', v):
raise ValueError('Password must contain at least one lowercase letter')
if not re.search(r'[0-9]', v):
raise ValueError('Password must contain at least one digit')
if not re.search(r'[^a-zA-Z0-9]', v):
raise ValueError('Password must contain at least one special character')
return v

@validator('birth_date')
def validate_age(cls, v):
today = date.today()
age = today.year - v.year - ((today.month, today.day) < (v.month, v.day))
if age < 13:
raise ValueError('Users must be at least 13 years old')
if age > 120:
raise ValueError('Invalid birth date')
return v

@root_validator
def passwords_match(cls, values):
pw1, pw2 = values.get('password'), values.get('confirm_password')
if pw1 and pw2 and pw1 != pw2:
raise ValueError('Passwords do not match')

if not values.get('terms_accepted'):
raise ValueError('You must accept the terms and conditions')

return values

class Config:
schema_extra = {
"example": {
"username": "johndoe42",
"email": "[email protected]",
"password": "SecureP@ss123",
"confirm_password": "SecureP@ss123",
"birth_date": "1990-01-01",
"bio": "Hello, I'm John!",
"terms_accepted": True
}
}

@app.post("/register/", response_model=dict)
async def register_user(user: UserRegistration):
# In a real application, you would:
# 1. Check if username or email already exists in database
# 2. Hash the password
# 3. Store the user in the database
# 4. Generate a confirmation email or token

# For demonstration purposes, we'll just return success
return {
"message": "Registration successful",
"username": user.username,
"email": user.email,
"registered_at": datetime.now().isoformat()
}

This example demonstrates a comprehensive registration form that:

  • Validates username format
  • Ensures strong passwords
  • Checks that passwords match
  • Verifies the user is at least 13 years old
  • Requires terms acceptance
  • Provides an example in the API documentation

Summary

Field validation in FastAPI using Pydantic provides a powerful way to ensure data integrity with minimal code. We've explored:

  • Basic field validation using built-in constraints
  • Custom validators for more complex rules
  • Cross-field validation for interdependent fields
  • Advanced techniques like root validators and field aliases
  • Real-world examples showing practical applications

By leveraging these validation capabilities, you can build robust APIs that gracefully handle invalid inputs and provide clear error messages to users, all while keeping your code clean and maintainable.

Additional Resources

Exercises

  1. Create a model for a blog post with appropriate validations (title length, content requirements, etc.)
  2. Implement a credit card validation model that checks for valid format, expiration date, etc.
  3. Build an address validation model that ensures postal codes match country formats
  4. Create a model for a product inventory system with cross-field validations (e.g., making sure low stock alerts are set appropriately)
  5. Implement custom error messages for all validations in one of the above exercises


If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)