FastAPI Complete Course FastAPI Module 5

FastAPI Complete Course Module 5: Data Validation

Introduction

Welcome to Module 5 of the FastAPI Complete Course. In the previous modules, you learned how to build API endpoints, handle path and query parameters, and structure your application. Now, we dive into one of the most critical aspects of building robust APIs: data validation.

When users send data to your API, you cannot trust it blindly. Invalid data can crash your application, corrupt your database, or create security vulnerabilities. FastAPI, built on top of Pydantic, provides an incredibly powerful and intuitive data validation system that handles all of this for you.

In this chapter, you will learn how to use FastAPI data validation to ensure that your API only accepts clean, correct data. We will cover type checking, string and number validation, email validation, custom validators, and error handling. By the end, you will be able to build APIs that are both developer-friendly and robust.

Type Checking with Pydantic

The foundation of FastAPI data validation is Pydantic. Pydantic is a Python library that uses Python type hints to validate data. FastAPI integrates with Pydantic seamlessly, allowing you to define request bodies, query parameters, and path parameters with automatic validation.

Defining Your First Pydantic Model

Let’s start with a simple example. Imagine you are building an API for a user registration system. You want to ensure that the incoming data has the correct types.

from pydantic import BaseModel
from typing import Optional

class UserCreate(BaseModel):
    username: str
    email: str
    age: int
    is_active: bool = True
    bio: Optional[str] = None

Explanation:

  • BaseModel is the base class for all Pydantic models.
  • Each field has a type hint: str, int, bool.
  • is_active has a default value of True, making it optional.
  • bio is an optional string. If not provided, it defaults to None.

Now, let’s use this model in a FastAPI endpoint:

from fastapi import FastAPI
from pydantic import BaseModel
from typing import Optional

app = FastAPI()

class UserCreate(BaseModel):
    username: str
    email: str
    age: int
    is_active: bool = True
    bio: Optional[str] = None

@app.post("/users/")
async def create_user(user: UserCreate):
    return {
        "username": user.username,
        "email": user.email,
        "age": user.age,
        "is_active": user.is_active,
        "bio": user.bio
    }

When a client sends a POST request to /users/ with a JSON body, FastAPI automatically validates the data. If someone sends {"username": "john", "email": "john@example.com", "age": "twenty"}, FastAPI will return a 422 Unprocessable Entity error because age must be an integer.

Why Type Checking Matters

  • Safety: Prevents runtime errors caused by unexpected data types.
  • Documentation: The type hints serve as live documentation for your API.
  • Auto-completion: IDEs can provide better code completion when types are defined.

String Validation

While basic type checking ensures that a field is a string, you often need more specific constraints. For example, a username should be between 3 and 20 characters, or a password must contain at least one number.

Pydantic provides the Field class from Pydantic’s Field function to add constraints to your fields.

Using Field Constraints for Strings

from pydantic import BaseModel, Field
from typing import Optional

class UserCreate(BaseModel):
    username: str = Field(..., min_length=3, max_length=20, regex="^[a-zA-Z0-9_]+$")
    password: str = Field(..., min_length=8, max_length=50)
    bio: Optional[str] = Field(None, max_length=500)

Explanation:

  • Field(...) means the field is required (ellipsis ... indicates required).
  • min_length=3 and max_length=20 restrict the string length.
  • regex="^[a-zA-Z0-9_]+$" ensures the username contains only letters, numbers, and underscores.
  • bio is optional, but if provided, it cannot exceed 500 characters.

Common String Validators

  • min_length – minimum number of characters.
  • max_length – maximum number of characters.
  • regex – regular expression pattern matching.
  • to_lower – automatically convert to lowercase.
  • strip_whitespace – remove leading/trailing whitespace.

Number Validation

Numbers in APIs often need constraints like minimum or maximum values, or being positive or negative. Pydantic’s Field also supports numeric constraints.

Validating Integers and Floats

from pydantic import BaseModel, Field
from typing import Optional

class ProductCreate(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    price: float = Field(..., gt=0, le=10000)
    quantity: int = Field(..., ge=0, le=1000)
    discount: Optional[float] = Field(None, ge=0, le=100)

Explanation:

  • gt=0 means greater than 0 (price must be positive).
  • le=10000 means less than or equal to 10000.
  • ge=0 means greater than or equal to 0 (quantity cannot be negative).
  • discount is optional, but if provided, must be between 0 and 100 (percentage).

Available Number Constraints

  • gt – greater than
  • ge – greater than or equal to
  • lt – less than
  • le – less than or equal to
  • multiple_of – must be a multiple of a given number

Email Validation

Email validation is a common requirement, but writing a perfect email regex is notoriously difficult. Pydantic provides a special type called EmailStr that handles this for you.

Using EmailStr

First, you need to install the email-validator library:

pip install pydantic[email]

Now, you can use EmailStr in your models:

from pydantic import BaseModel, EmailStr

class UserCreate(BaseModel):
    username: str = Field(..., min_length=3, max_length=20)
    email: EmailStr
    age: int = Field(..., ge=18, le=120)

Explanation:

  • EmailStr automatically validates that the string is a valid email format.
  • It checks for the presence of @, a valid domain, and proper structure.
  • If the email is invalid, FastAPI returns a clear error message.

Why Use EmailStr Instead of Regex?

  • Accuracy: The library follows the official email specification (RFC 5321/5322).
  • Maintenance: You don’t need to write or maintain complex regex patterns.
  • Performance: It’s optimized for validation.

Custom Validators

Sometimes, built-in validators are not enough. For example, you might want to ensure that a username is not already taken, or that a password meets specific security requirements. Pydantic allows you to create custom validators using the @validator decorator.

Creating a Simple Custom Validator

from pydantic import BaseModel, Field, validator
from typing import Optional

class UserCreate(BaseModel):
    username: str = Field(..., min_length=3, max_length=20)
    password: str = Field(..., min_length=8)
    confirm_password: str

    @validator('confirm_password')
    def passwords_match(cls, v, values):
        if 'password' in values and v != values['password']:
            raise ValueError('Passwords do not match')
        return v

Explanation:

  • The @validator decorator takes the field name as an argument.
  • The method name can be anything, but it’s common to use a descriptive name.
  • The first parameter cls is the class itself.
  • The second parameter v is the value being validated.
  • The third parameter values is a dictionary of all previously validated fields.
  • If validation fails, raise a ValueError with a descriptive message.

Validating Multiple Fields

You can also create validators that check multiple fields together:

from pydantic import BaseModel, validator
from datetime import date

class EventCreate(BaseModel):
    name: str
    start_date: date
    end_date: date

    @validator('end_date')
    def end_date_after_start(cls, v, values):
        if 'start_date' in values and v <= values['start_date']:
            raise ValueError('End date must be after start date')
        return v

Pre-validators and Post-validators

By default, validators run after the type conversion. You can use pre=True to run them before type conversion:

from pydantic import BaseModel, validator

class ProductCreate(BaseModel):
    sku: str

    @validator('sku', pre=True)
    def normalize_sku(cls, v):
        if isinstance(v, str):
            return v.strip().upper()
        return v

This ensures that the SKU is always uppercase and trimmed, regardless of how the client sends it.

Error Handling

FastAPI automatically returns detailed validation errors when data is invalid. However, you can customize how these errors are presented to your users.

Understanding Default Error Responses

When validation fails, FastAPI returns a 422 Unprocessable Entity response with a JSON body like this:

{
    "detail": [
        {
            "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 or equal to 18",
            "type": "value_error.number.not_ge"
        }
    ]
}

Explanation:

  • loc tells you exactly where the error occurred (body, path, query).
  • msg is a human-readable error message.
  • type is a machine-readable error type.

Customizing Error Responses

You can override the default validation error handler to change the response format:

from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse

app = FastAPI()

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    errors = []
    for error in exc.errors():
        errors.append({
            "field": " -> ".join([str(loc) for loc in error["loc"]]),
            "message": error["msg"],
            "error_code": error["type"]
        })
    return JSONResponse(
        status_code=422,
        content={"success": False, "errors": errors}
    )

This gives you full control over the error response structure, which is useful for frontend teams or API consumers.

Handling Validation Errors in Business Logic

Sometimes, you need to validate data based on business rules that cannot be expressed with simple constraints. For example, checking if a username is already taken:

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

fake_users_db = {"admin", "root", "test"}

class UserCreate(BaseModel):
    username: str

@app.post("/users/")
async def create_user(user: UserCreate):
    if user.username in fake_users_db:
        raise HTTPException(
            status_code=400,
            detail=f"Username '{user.username}' is already taken"
        )
    # Proceed with user creation
    return {"message": "User created successfully"}

Common Mistakes

  1. Forgetting to import Field: Many beginners use Field without importing it. Always add from pydantic import Field.
  2. Using the wrong constraint name: Remember that gt means greater than, not greater than or equal. Use ge for the latter.
  3. Not handling optional fields correctly: Use Optional[type] from typing and set a default of None.
  4. Over-validating: Adding too many constraints can make your API inflexible. Only validate what is necessary for data integrity.
  5. Ignoring error responses: Always test what your API returns when validation fails. It helps frontend developers debug issues.
  6. Using EmailStr without installing pydantic[email]: You’ll get an import error if the email-validator library is not installed.

Practice Task

Now it’s your turn to apply what you’ve learned. Create a FastAPI endpoint for a book review system.

Requirements:

  1. Create a Pydantic model called BookReview with the following fields:
    • title: string, required, 1-100 characters
    • author: string, required, 1-50 characters
    • rating: integer, required, between 1 and 5
    • review_text: string, optional, max 1000 characters
    • email: valid email address, required
  2. Add a custom validator that ensures rating is not 3 (because nobody writes reviews for average books).
  3. Create a POST endpoint /reviews/ that accepts this model and returns the validated data.
  4. Test your endpoint with both valid and invalid data using a tool like curl or Postman.

Bonus: Add a custom error handler that returns errors in a format like {"error": "field_name: error_message"}.

Summary

In this module, you learned the essentials of FastAPI data validation:

  • Type checking with Pydantic models ensures that your API receives the correct data types.
  • String validation using Field constraints like min_length, max_length, and regex.
  • Number validation with constraints such as gt, ge, lt, and le.
  • Email validation using the powerful EmailStr type.
  • Custom validators with the @validator decorator for complex business rules.
  • Error handling including customizing the default 422 error responses.

Data validation is not just about preventing errors—it’s about building APIs that are self-documenting, secure, and developer-friendly. With FastAPI and Pydantic, you get all of this with minimal code.

FAQs

1. What is the difference between Field(...) and Field(None)?

Field(...) makes the field required—the client must provide a value. Field(None) makes the field optional, with a default value of None if not provided.

2. Can I use regular Python type hints without Pydantic?

No. FastAPI relies on Pydantic for validation. Simple type hints like str or int are only used for documentation and basic type coercion, not validation.

3. How do I validate nested JSON objects?

You can nest Pydantic models inside each other. For example, a User model can have an address field that is itself a Pydantic model with its own validation.

4. Why does my custom validator not run?

Make sure you have the @validator decorator correctly applied. Also, validators run in the order fields are defined. If your validator depends on another field, ensure that field is defined before the validator.

5. Can I reuse validation logic across multiple models?

Yes! You can create a base model with common validators and inherit from it. You can also define standalone validator functions and use them in multiple models.


Next up: Module 6 – Database Integration with SQLAlchemy. You’ll learn how to connect your validated data to a real database, perform CRUD operations, and handle database relationships. Your solid understanding of data validation will be the foundation for building production-ready APIs. Keep coding!

Additional Practical Example

Let’s build a more realistic data validation scenario that combines multiple Pydantic features you’ve learned. We’ll create an e-commerce product API with nested models, custom validators, and conditional validation.

First, define the models with proper validation:

from datetime import datetime
from decimal import Decimal
from typing import List, Optional
from pydantic import BaseModel, Field, validator, condecimal, conint

class ProductVariant(BaseModel):
    sku: str = Field(..., min_length=5, max_length=20, 
                     description="Stock Keeping Unit code")
    name: str = Field(..., min_length=1, max_length=100)
    price: condecimal(max_digits=10, decimal_places=2) = Field(..., gt=0)
    stock: conint(ge=0) = Field(0, description="Current inventory count")
    is_active: bool = True
    
    @validator('sku')
    def validate_sku_format(cls, v):
        """Ensure SKU follows pattern: 2 letters + 4 digits + optional suffix"""
        import re
        if not re.match(r'^[A-Z]{2}d{4}[A-Z0-9]{0,6}$', v):
            raise ValueError('SKU must start with 2 uppercase letters '
                           'followed by 4 digits and optional alphanumeric suffix')
        return v

class ProductCategory(BaseModel):
    name: str = Field(..., min_length=2, max_length=50)
    slug: str = Field(..., regex=r'^[a-z0-9]+(?:-[a-z0-9]+)*$')
    parent_category: Optional[str] = None

class Product(BaseModel):
    id: Optional[int] = None
    name: str = Field(..., min_length=3, max_length=200)
    description: str = Field(..., min_length=10, max_length=2000)
    category: ProductCategory
    variants: List[ProductVariant] = Field(..., min_items=1, max_items=50)
    tags: List[str] = Field(default_factory=list, max_length=10)
    created_at: datetime = Field(default_factory=datetime.utcnow)
    updated_at: Optional[datetime] = None
    
    @validator('tags', each_item=True)
    def validate_tag_format(cls, v):
        """Ensure each tag is lowercase and has no spaces"""
        if ' ' in v or v != v.lower():
            raise ValueError('Tags must be lowercase with no spaces')
        return v
    
    @validator('name')
    def name_must_be_unique_case_insensitive(cls, v, values):
        """Simulate checking for unique product names (case-insensitive)"""
        # In real app, you'd query the database
        existing_names = {'laptop', 'phone', 'tablet'}
        if v.lower() in existing_names:
            raise ValueError(f'Product name "{v}" already exists')
        return v
    
    @validator('variants')
    def validate_variant_prices_consistency(cls, v):
        """Ensure no variant has a price 50% higher than the cheapest variant"""
        if len(v) > 1:
            prices = [variant.price for variant in v]
            min_price = min(prices)
            max_price = max(prices)
            if max_price > min_price * Decimal('1.5'):
                raise ValueError('No variant can be 50% more expensive than the cheapest variant')
        return v

class ProductCreateResponse(BaseModel):
    success: bool
    product_id: int
    message: str = "Product created successfully"

Now let’s create the FastAPI endpoints with proper error handling:

from fastapi import FastAPI, HTTPException, Depends, status
from typing import List

app = FastAPI(title="E-commerce Product API")

# In-memory storage (replace with database in production)
products_db = {}
product_counter = 0

@app.post("/products/", response_model=ProductCreateResponse, 
          status_code=status.HTTP_201_CREATED)
async def create_product(product: Product):
    global product_counter
    
    try:
        # Validate the product (Pydantic does this automatically)
        product_data = product.dict()
        
        # Simulate database operation
        product_counter += 1
        product_id = product_counter
        product_data['id'] = product_id
        products_db[product_id] = product_data
        
        return ProductCreateResponse(
            success=True,
            product_id=product_id,
            message=f"Product '{product.name}' created with {len(product.variants)} variants"
        )
    except Exception as e:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Failed to create product: {str(e)}"
        )

@app.get("/products/{product_id}", response_model=Product)
async def get_product(product_id: int):
    product = products_db.get(product_id)
    if not product:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Product with ID {product_id} not found"
        )
    return product

@app.get("/products/", response_model=List[Product])
async def list_products(skip: int = 0, limit: int = 10):
    product_list = list(products_db.values())
    return product_list[skip:skip + limit]

Let’s test the validation with a sample request:

# Example valid product creation
valid_product = {
    "name": "Wireless Mouse Pro",
    "description": "High-performance wireless mouse with ergonomic design and long battery life.",
    "category": {
        "name": "Computer Accessories",
        "slug": "computer-accessories"
    },
    "variants": [
        {
            "sku": "WM00100BLACK",
            "name": "Black Edition",
            "price": 49.99,
            "stock": 100
        },
        {
            "sku": "WM00100WHITE",
            "name": "White Edition",
            "price": 54.99,
            "stock": 75
        }
    ],
    "tags": ["wireless", "ergonomic", "mouse"]
}

# Example invalid product (will trigger validation errors)
invalid_product = {
    "name": "Phone",  # This will fail because "phone" already exists
    "description": "Short",  # Too short (min 10 chars)
    "category": {
        "name": "Electronics",
        "slug": "Invalid Slug!!!"  # Invalid slug format
    },
    "variants": [
        {
            "sku": "INVALID",  # Doesn't match SKU pattern
            "name": "",
            "price": -10,  # Negative price
            "stock": -5  # Negative stock
        }
    ],
    "tags": ["Invalid Tag With Spaces"]  # Invalid tag format
}

This practical example demonstrates:

  • Nested models with Product containing Category and Variants
  • Field constraints using min_length, max_length, regex, and decimal precision
  • Custom validators for SKU format, tag formatting, and business rules
  • Conditional validation checking variant price consistency
  • Error handling with proper HTTP status codes and messages

Class-Based Implementation Example

While FastAPI works excellently with functions, class-based views can help organize related endpoints and share common dependencies. Here’s how to implement data validation using class-based approach:

from fastapi import APIRouter, Depends, HTTPException, status
from typing import List, Dict, Optional
from pydantic import BaseModel, Field, validator

# Shared validation models
class UserBase(BaseModel):
    username: str = Field(..., min_length=3, max_length=50, regex=r'^[a-zA-Z0-9_]+$')
    email: str = Field(..., regex=r'^[w.-]+@[w.-]+.w+$')
    
    @validator('username')
    def username_no_special_chars(cls, v):
        if not v.isalnum() and '_' not in v:
            raise ValueError('Username can only contain letters, numbers, and underscores')
        return v

class UserCreate(UserBase):
    password: str = Field(..., min_length=8, max_length=100)
    confirm_password: str
    
    @validator('confirm_password')
    def passwords_match(cls, v, values):
        if 'password' in values and v != values['password']:
            raise ValueError('Passwords do not match')
        return v
    
    @validator('password')
    def password_strength(cls, v):
        """Check password has at least one uppercase, one lowercase, and one digit"""
        if not any(c.isupper() for c in v):
            raise ValueError('Password must contain at least one uppercase letter')
        if not any(c.islower() for c in v):
            raise ValueError('Password must contain at least one lowercase letter')
        if not any(c.isdigit() for c in v):
            raise ValueError('Password must contain at least one digit')
        return v

class UserUpdate(BaseModel):
    username: Optional[str] = Field(None, min_length=3, max_length=50)
    email: Optional[str] = Field(None, regex=r'^[w.-]+@[w.-]+.w+$')
    bio: Optional[str] = Field(None, max_length=500)

class UserResponse(UserBase):
    id: int
    is_active: bool = True
    created_at: str

# Class-based router
class UserRouter:
    def __init__(self):
        self.router = APIRouter(prefix="/users", tags=["users"])
        self.users_db: Dict[int, dict] = {}
        self.counter = 0
        
        # Register routes
        self.router.add_api_route("/", self.create_user, methods=["POST"], 
                                  response_model=UserResponse, status_code=201)
        self.router.add_api_route("/{user_id}", self.get_user, methods=["GET"],
                                  response_model=UserResponse)
        self.router.add_api_route("/{user_id}", self.update_user, methods=["PATCH"],
                                  response_model=UserResponse)
        self.router.add_api_route("/{user_id}", self.delete_user, methods=["DELETE"],
                                  status_code=204)
    
    async def create_user(self, user: UserCreate):
        """Create a new user with full validation"""
        self.counter += 1
        user_id = self.counter
        
        # Simulate email uniqueness check
        for existing_user in self.users_db.values():
            if existing_user['email'] == user.email:
                raise HTTPException(
                    status_code=status.HTTP_409_CONFLICT,
                    detail="Email already registered"
                )
        
        user_data = user.dict(exclude={'confirm_password'})
        user_data['id'] = user_id
        user_data['is_active'] = True
        user_data['created_at'] = "2024-01-15T10:30:00Z"
        
        # Hash password (in real app)
        user_data['hashed_password'] = f"hashed_{user.password}"
        del user_data['password']
        
        self.users_db[user_id] = user_data
        return UserResponse(**user_data)
    
    async def get_user(self, user_id: int):
        """Retrieve a user by ID"""
        user = self.users_db.get(user_id)
        if not user:
            raise HTTPException(
                status_code=status.HTTP_404_NOT_FOUND,
                detail=f"User with ID {user_id} not found"
            )
        return UserResponse(**user)
    
    async def update_user(self, user_id: int, user_update: UserUpdate):
        """Partially update user information"""
        if user_id not in self.users_db:
            raise HTTPException(status_code=404, detail="User not found")
        
        # Only update provided fields
        update_data = user_update.dict(exclude_unset=True)
        
        # Validate email uniqueness if changing
        if 'email' in update_data:
            for uid, existing in self.users_db.items():
                if uid != user_id and existing['email'] == update_data['email']:
                    raise HTTPException(
                        status_code=status.HTTP_409_CONFLICT,
                        detail="Email already in use"
                    )
        
        self.users_db[user_id].update(update_data)
        return UserResponse(**self.users_db[user_id])
    
    async def delete_user(self, user_id: int):
        """Delete a user"""
        if user_id not in self.users_db:
            raise HTTPException(status_code=404, detail="User not found")
        del self.users_db[user_id]

# Register the router
user_router = UserRouter()
app.include_router(user_router.router)

Class-based implementation advantages:

  • State management – Shared database connection or configuration across endpoints
  • Code organization – Related endpoints grouped logically
  • Reusable validation – Models defined once, used across methods
  • Easier testing – Can instantiate the class with mock dependencies

Hands-On Practice Task

Now it’s your turn to apply what you’ve learned. Build a blog post API with the following requirements:

Task: Create a Blog Post Management System

Implement the following endpoints:

  1. POST /posts/ – Create a new blog post with validation
  2. GET /posts/{post_id} – Retrieve a specific post
  3. PATCH /posts/{post_id} – Update post fields (partial update)
  4. DELETE /posts/{post_id} – Delete a post

Validation Requirements:

  • Title: 5-100 characters, must start with a capital letter
  • Content: 50-10000 characters, cannot be HTML (strip tags validation)
  • Author: Must be a valid email format
  • Tags: List of 1-5 tags, each 2-20 lowercase characters with no spaces
  • Status: Must be one of: “draft”, “published”, “archived”
  • Published date: Optional, but if status is “published”, it’s required
  • Slug: Auto-generated from title (lowercase, hyphens instead of spaces), must be unique

Starter Code:

from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field, validator
from typing import List, Optional
from datetime import datetime
import re

app = FastAPI(title="Blog Post API")

# TODO: Define your Pydantic models here
class PostCreate(BaseModel):
    # Add fields with proper validation
    pass

class PostUpdate(BaseModel):
    # Add fields for partial update
    pass

class PostResponse(BaseModel):
    # Define response model
    pass

# TODO: Implement your endpoints
# @app.post("/posts/")
# async def create_post(post: PostCreate):
#     pass

# @app.get("/posts/{post_id}")
# async def get_post(post_id: int):
#     pass

# @app.patch("/posts/{post_id}")
# async def update_post(post_id: int, post: PostUpdate):
#     pass

# @app.delete("/posts/{post_id}")
# async def delete_post(post_id: int):
#     pass

Expected Behavior:

  • Valid posts should return 201 status with the created post data
  • Invalid data should return 422 with clear error messages
  • Attempting to publish without a date should fail
  • Duplicate slugs should return 409 Conflict
  • Non-existent posts should return 404

Bonus Challenges:

  1. Add a custom validator that checks for profanity in title and content
  2. Implement pagination for listing all posts
  3. Add a search endpoint that filters by title, content, or tags
  4. Create a class-based version of your router

Test your implementation with these sample requests:

# Valid post creation
valid_post = {
    "title": "Getting Started with FastAPI",
    "content": "FastAPI is a modern web framework for building APIs with Python...",
    "author": "author@example.com",
    "tags": ["fastapi", "python", "tutorial"],
    "status": "draft"
}

# Invalid post (should fail validation)
invalid_post = {
    "title": "short",  # Too short
    "content": "Too short content",  # Too short
    "author": "not-an-email",  # Invalid email
    "tags": ["INVALID TAG WITH SPACES"],  # Invalid tag format
    "status": "unknown_status"  # Invalid status
}

Common Interview Questions

Here are frequently asked interview questions about data validation in FastAPI, with detailed explanations:

Q1: What’s the difference between Pydantic’s Field and validator?

A: Field is used for declarative validation – defining constraints like minimum length, maximum value, regex patterns, and default values. It’s best for simple, static validation rules. validator is used for imperative validation – complex logic that might depend on multiple fields, external data, or require computation. Use Field for 80% of cases and validator for the remaining 20% where you need custom logic.

Q2: How do you handle optional fields with conditional validation?

A: Use Optional[type] for the field type and set a default of None. Then use @validator with pre=True to check conditions before other validation runs. For example, requiring a published_date only when status is “published”:

class Post(BaseModel):
    status: str
    published_date: Optional[datetime] = None
    
    @validator('published_date', always=True)
    def validate_published_date(cls, v, values):
        if values.get('status') == 'published' and v is None:
            raise ValueError('Published date required when status is published')
        return v

Q3: How does FastAPI handle validation errors and return them to the client?

A: FastAPI automatically catches Pydantic validation errors and returns a 422 Unprocessable Entity response with a structured JSON body containing details about each validation failure. The response includes the field name, error message, and the invalid value. You can customize this behavior by creating a custom exception handler:

from fastapi import Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    return JSONResponse(
        status_code=422,
        content={
            "detail": exc.errors(),
            "body": exc.body,
            "custom_message": "Validation failed. Please check your input."
        }
    )

Q4: What’s the purpose of response_model in endpoint decorators?

A: response_model serves multiple purposes: it validates the response data before sending it to the client, filters out fields not defined in the model, automatically converts types (e.g., datetime to string), and provides OpenAPI documentation. It’s a powerful tool for ensuring API consistency and security by preventing accidental data leaks.

Q5: How do you implement custom error messages for validation?

A: Custom error messages can be implemented in several ways:

  1. In Field using the description parameter for documentation
  2. In validator using custom ValueError messages
  3. Using Pydantic’s ValidationError subclassing
  4. Creating custom exception handlers for specific error types

Example with custom messages:

class UserCreate(BaseModel):
    age: int = Field(..., ge=18, description="User must be at least 18 years old")
    
    @validator('age')
    def validate_age(cls, v):
        if v  120:
            raise ValueError('Please enter a valid age')
        return v

Q6: Can you explain the difference between BaseModel and dataclass in Python?

A: Both are used for data containers, but Pydantic’s BaseModel offers built-in validation, type coercion, JSON serialization, and integration with FastAPI. Python’s dataclass is lighter but lacks validation features. For FastAPI applications, always use BaseModel (or TypeAdapter for simpler cases) to leverage automatic request validation and documentation generation.

Q7: How do you handle file uploads with validation?

A: Use FastAPI’s UploadFile and File classes, then validate file properties like size, type, and extension in your endpoint logic:

from fastapi import UploadFile, File, HTTPException

@app.post("/upload/")
async def upload_file(file: UploadFile = File(...)):
    # Validate file size (e.g., max 5MB)
    contents = await file.read()
    if len(contents) > 5 * 1024 * 1024:
        raise HTTPException(400, "File too large")
    
    # Validate file type
    allowed_types = ["image/jpeg", "image/png", "application/pdf"]
    if file.content_type not in allowed_types:
        raise HTTPException(400, "Invalid file type")
    
    # Validate file extension
    import os
    ext = os.path.splitext(file.filename)[1].lower()
    if ext not in ['.jpg', '.jpeg', '.png', '.pdf']:
        raise HTTPException(400, "Invalid file extension")
    
    return {"filename": file.filename, "size": len(contents)}

Q8: What are the best practices for organizing validation logic in large FastAPI projects?

A: Follow these practices:

  • Create separate modules for models (e.g., models/user.py, models/product.py)
  • Use mixin classes for reusable validation logic
  • Keep validators focused – one validator per concern
  • Use pre=True validators for data transformation before validation
  • Document validation rules with clear error messages
  • Consider using constr, conint, condecimal for common patterns
  • Test validation logic separately from endpoint logic

Remember: Good validation is your API’s first line of defense against incorrect data. Invest time in designing comprehensive validation rules that catch errors early and provide helpful feedback to API consumers.

Leave a Reply

Your email address will not be published. Required fields are marked *