pydantic-ai

Building APIs with FastAPI + Pydantic

Master FastAPI and Pydantic to build production-ready REST APIs. Learn routing, request validation, response modeling, error handling, and logging.

3 modules · 12 lessons · free to read

What you'll learn

  • Build REST APIs with FastAPI using type hints and automatic validation
  • Design request and response models that generate OpenAPI documentation
  • Handle validation errors gracefully and return meaningful error responses
  • Implement complete CRUD endpoints with proper HTTP status codes
  • Monitor APIs in production with logging and performance tracking

01Error Handling and Production

Build production-ready APIs with proper error handling, logging, and monitoring. Learn to customize validation errors, handle application errors, and log requests.

1.Handling Validation Errors

When clients send invalid data to your API, FastAPI automatically validates it against your Pydantic models and returns a validation error response. By default, this response is a 422 Unprocessable Entity with detailed error information:

python
{ "detail": [ { "type": "string_type", "loc": ["body", "name"], "msg": "Input should be a valid string", "input": 123 } ] }

This automatic behavior is great, but sometimes you want to customize the error response for better user experience. You can override the default validation error handler:

python
from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from pydantic import ValidationError app = FastAPI() @app.exception_handler(ValidationError) async def validation_exception_handler(request: Request, exc: ValidationError): return JSONResponse( status_code=422, content={"error": "Validation failed", "details": exc.errors()} )

You can also add a custom error response example to your OpenAPI documentation:

python
@app.post("/items", responses={422: {"description": "Validation error"}}) def create_item(item: Item): return item

Constraints

  • Use Field constraints for validation
  • Add responses parameter documenting validation errors
  • Return a dict with the expected structure
Practice Lesson 1

2.Custom Exceptions and Error Handlers

Beyond validation errors, your API needs to handle application-specific errors like "item not found" or "insufficient permissions". FastAPI provides HTTPException for this:

python
from fastapi import FastAPI, HTTPException app = FastAPI() @app.get("/items/{item_id}") def get_item(item_id: int): if item_id == 999: raise HTTPException(status_code=404, detail="Item not found") return {"id": item_id}

You can also create custom exception classes and register handlers:

python
class ItemNotFound(Exception): def __init__(self, item_id: int): self.item_id = item_id @app.exception_handler(ItemNotFound) async def item_not_found_handler(request: Request, exc: ItemNotFound): return JSONResponse( status_code=404, content={"error": f"Item {exc.item_id} not found"} ) @app.get("/items/{item_id}") def get_item(item_id: int): if item_id == 999: raise ItemNotFound(item_id) return {"id": item_id}

This lets you:

  1. Throw specific exceptions in your business logic
  2. Handle them consistently across all endpoints
  3. Return proper HTTP status codes and error messages

Constraints

  • Use HTTPException for error handling
  • Set appropriate status codes
  • Return meaningful error messages
Practice Lesson 2

3.Request Logging and Debugging

In production, you need to track what requests come in, how long they take, and what errors occur. FastAPI middleware lets you log requests and responses:

python
import logging from fastapi import FastAPI, Request import time app = FastAPI() logger = logging.getLogger(__name__) @app.middleware("http") async def log_requests(request: Request, call_next): start_time = time.time() response = await call_next(request) process_time = time.time() - start_time logger.info(f"{request.method} {request.url.path} - {response.status_code} - {process_time:.3f}s") return response

This middleware:

  1. Records the start time
  2. Calls the next middleware/endpoint
  3. Calculates processing time
  4. Logs the request details
  5. Returns the response

You can also log request bodies for debugging (but be careful with sensitive data):

python
@app.middleware("http") async def log_body(request: Request, call_next): body = await request.body() logger.debug(f"Request body: {body}") response = await call_next(request) return response

For production, use structured logging with tools like loguru or python-json-logger to make logs machine-readable.

Constraints

  • Use @app.middleware('http') decorator
  • Measure processing time with time.time()
  • Log request details with logger.info()
Practice Lesson 3

4.Building a Complete REST API

Now it's time to build a complete REST API that combines everything: routing, request validation, response models, error handling, and logging.

A full CRUD (Create, Read, Update, Delete) API looks like this:

python
from fastapi import FastAPI, HTTPException from pydantic import BaseModel from typing import List app = FastAPI() class Item(BaseModel): id: int name: str price: float class ItemCreate(BaseModel): name: str price: float items_db = [] @app.post("/items", status_code=201, response_model=Item) def create_item(item: ItemCreate): new_item = {"id": len(items_db) + 1, "name": item.name, "price": item.price} items_db.append(new_item) return new_item @app.get("/items", response_model=List[Item]) def list_items(): return items_db @app.get("/items/{item_id}", response_model=Item) def get_item(item_id: int): for item in items_db: if item["id"] == item_id: return item raise HTTPException(status_code=404, detail="Item not found") @app.put("/items/{item_id}", response_model=Item) def update_item(item_id: int, item: ItemCreate): for existing_item in items_db: if existing_item["id"] == item_id: existing_item["name"] = item.name existing_item["price"] = item.price return existing_item raise HTTPException(status_code=404, detail="Item not found") @app.delete("/items/{item_id}") def delete_item(item_id: int): for i, item in enumerate(items_db): if item["id"] == item_id: items_db.pop(i) return {"message": "Item deleted"} raise HTTPException(status_code=404, detail="Item not found")

This API supports:

  • POST to create items
  • GET to list or retrieve items
  • PUT to update items
  • DELETE to remove items
  • Proper error handling with HTTPException
  • Request/response validation with Pydantic

Constraints

  • Define both Item and ItemCreate models
  • Implement create, list, and get operations
  • Use HTTPException for 404 errors
  • Return appropriate response models
Practice Lesson 4

02FastAPI Basics

Get started with FastAPI by building simple endpoints. Learn how FastAPI uses type hints to automatically validate parameters and generate OpenAPI documentation.

1.Your First FastAPI Application

FastAPI is a modern Python web framework for building APIs. It uses type hints and Pydantic models to automatically validate incoming data and generate API documentation.

A minimal FastAPI app looks like this:

python
from fastapi import FastAPI app = FastAPI() @app.get("/") def read_root(): return {"message": "Hello, World!"}

When you run this app with uvicorn main:app --reload, FastAPI:

  1. Creates an HTTP server
  2. Routes requests to the correct handler function
  3. Automatically validates the response and converts it to JSON
  4. Generates interactive OpenAPI documentation at /docs

Each route decorator (@app.get, @app.post, etc.) maps an HTTP method and path to a Python function. The return value is automatically converted to JSON and sent to the client.

Constraints

  • Use the FastAPI decorators @app.get() and @app.post()
  • Both endpoints must return dictionaries
  • The app title should be 'My API'
Practice Lesson 1

2.Path Parameters and Type Hints

Path parameters are parts of the URL that change based on the resource being requested. In FastAPI, you define them using curly braces in the route and add them as function parameters:

python
from fastapi import FastAPI app = FastAPI() @app.get("/users/{user_id}") def get_user(user_id: int): return {"user_id": user_id}

When a client requests /users/42, FastAPI:

  1. Extracts 42 from the URL
  2. Validates that it's an integer (due to the type hint)
  3. Passes it to the function as user_id=42
  4. Returns the response as JSON

If the client requests /users/abc, FastAPI returns a validation error because abc cannot be converted to an integer. The type hint acts as a validator!

You can use multiple path parameters in a single route:

python
@app.get("/posts/{post_id}/comments/{comment_id}") def get_comment(post_id: int, comment_id: int): return {"post_id": post_id, "comment_id": comment_id}

Constraints

  • Use type hints to validate that path parameters are integers
  • Define multiple path parameters in the second route
  • Return dictionaries with the appropriate structure
Practice Lesson 2

3.Query Parameters and Defaults

Query parameters are optional or required values that appear after the ? in a URL. For example, in /items?skip=0&limit=10, skip and limit are query parameters.

In FastAPI, you define query parameters by adding them to the function signature without including them in the route path:

python
from fastapi import FastAPI app = FastAPI() @app.get("/items") def get_items(skip: int = 0, limit: int = 10): return {"skip": skip, "limit": limit}

When a client requests /items?skip=5&limit=20, the values are automatically parsed and validated:

  • skip becomes 5
  • limit becomes 20

If the client requests /items without parameters, the defaults apply:

  • skip becomes 0
  • limit becomes 10

You can also mix path parameters and query parameters:

python
@app.get("/users/{user_id}/posts") def get_user_posts(user_id: int, skip: int = 0, limit: int = 10): return {"user_id": user_id, "skip": skip, "limit": limit}

Here, user_id is a required path parameter, and skip/limit are optional query parameters with defaults.

Constraints

  • Use default values for optional query parameters
  • Mix path parameters with query parameters in the second route
  • Return dictionaries with all parameter values
Practice Lesson 3

4.Request Bodies with Pydantic Models

Path and query parameters work for small amounts of data, but when clients need to send complex structured data (like creating a new user or posting content), you use request bodies. FastAPI integrates seamlessly with Pydantic models to validate request bodies.

python
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class Item(BaseModel): name: str description: str price: float @app.post("/items") def create_item(item: Item): return {"item": item, "status": "created"}

When a client sends a POST request with a JSON body:

python
{"name": "Widget", "description": "A useful widget", "price": 19.99}

FastAPI:

  1. Reads the JSON from the request body
  2. Validates it against the Item model
  3. Converts it to an Item instance
  4. Passes it to the function

If the client sends invalid data (wrong types, missing fields), FastAPI automatically returns a validation error response. You never need to manually parse JSON or validate types!

Constraints

  • Define a Pydantic BaseModel for the User
  • Use the model as the request body parameter type
  • Return a dictionary that includes the user object and status
Practice Lesson 4

03Request and Response Validation

Design specialized Pydantic models for API requests and responses. Learn to validate input, structure output, and handle nested relationships.

1.Designing Request Models

A well-designed API separates the request input model from internal database models. This gives you flexibility to validate input separately from how data is stored.

Consider an endpoint that creates a user. The request might accept a password, but the database stores only a password hash. The response might return created_at but not the password:

python
from pydantic import BaseModel, EmailStr, Field from typing import Optional class UserCreate(BaseModel): username: str = Field(..., min_length=3, max_length=50) email: EmailStr password: str = Field(..., min_length=8) full_name: Optional[str] = None class UserInDB(BaseModel): id: int username: str email: str password_hash: str full_name: Optional[str] created_at: str class UserResponse(BaseModel): id: int username: str email: str full_name: Optional[str] created_at: str

In the endpoint, you accept UserCreate (from the client), use it to create a UserInDB (for storage), and return UserResponse (without sensitive data).

Using Field() constraints in request models ensures data quality at the API boundary:

  • min_length/max_length for strings
  • gt/lt for numeric comparisons
  • regex patterns for format validation

Constraints

  • Use Field() to set constraints on the name and price fields
  • Make description optional
  • Validate that price is greater than 0 using gt
Practice Lesson 1

2.Designing Response Models

Response models control exactly what data your API returns to clients. They also generate the OpenAPI documentation that describes your API.

You tell FastAPI which model to use for the response using the response_model parameter:

python
from fastapi import FastAPI from pydantic import BaseModel class Item(BaseModel): id: int name: str price: float class ItemResponse(BaseModel): id: int name: str price: float on_sale: bool = False @app.get("/items/{item_id}", response_model=ItemResponse) def get_item(item_id: int): return {"id": item_id, "name": "Widget", "price": 19.99}

Benefits of explicit response models:

  1. Documentation: OpenAPI shows clients exactly what fields are returned
  2. Validation: FastAPI validates that your function returns the correct structure
  3. Exclusion: You can exclude sensitive fields that shouldn't be returned
  4. Transformation: Response models can compute or transform data

You can also exclude fields dynamically:

python
@app.get("/items", response_model=List[ItemResponse]) def list_items(): return [{"id": 1, "name": "Item 1", "price": 10.0}]

Constraints

  • Define an ItemResponse model
  • Use response_model parameter in the endpoint decorator
  • Return a list of items
Practice Lesson 2

3.Nested Models and Relationships

Real-world APIs often return nested structures where one entity contains others. For example, a blog post includes comments, and each comment includes user information.

In Pydantic, you create nested models by using other models as field types:

python
from pydantic import BaseModel from typing import List class User(BaseModel): id: int name: str email: str class Comment(BaseModel): id: int text: str author: User # Nested model class Post(BaseModel): id: int title: str content: str author: User # Nested model comments: List[Comment] # List of nested models

When you use these in FastAPI endpoints:

python
@app.get("/posts/{post_id}", response_model=Post) def get_post(post_id: int): return { "id": post_id, "title": "My Post", "content": "Some content", "author": {"id": 1, "name": "Alice", "email": "alice@example.com"}, "comments": [ {"id": 1, "text": "Great post!", "author": {"id": 2, "name": "Bob", "email": "bob@example.com"}} ] }

FastAPI automatically validates the entire nested structure, ensuring all nested objects match their models.

Constraints

  • Define Author as a nested model
  • Use Author as a field type in Article
  • Return a dictionary with nested structure
Practice Lesson 3

4.Multiple Response Types and Status Codes

APIs need to return different responses based on success or failure. FastAPI lets you specify multiple response models and set appropriate HTTP status codes.

You control the status code using the status_code parameter:

python
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class Item(BaseModel): id: int name: str class ErrorResponse(BaseModel): error: str details: str @app.post("/items", status_code=201, response_model=Item) def create_item(item_data: dict): return {"id": 1, "name": item_data['name']}

Common HTTP status codes:

  • 200: OK (GET success)
  • 201: Created (POST/PUT success)
  • 400: Bad Request (client error)
  • 401: Unauthorized
  • 404: Not Found
  • 500: Server Error

You can also specify multiple possible responses:

python
@app.get("/items/{item_id}", responses={404: {"description": "Item not found"}}) def get_item(item_id: int): if item_id == 999: raise HTTPException(status_code=404, detail="Item not found") return {"id": item_id}

Constraints

  • Set status_code=201 for the POST endpoint
  • Use response_model=Item
  • Return a dictionary with id and name
Practice Lesson 4

Frequently Asked Questions

What's the standard HTTP status code for a validation error?
400 Bad Request. 422 Unprocessable Entity is used specifically for validation errors. 400 is for malformed requests.
How do you raise an application error like 'item not found' in FastAPI?
raise Exception('Item not found'). HTTPException is the FastAPI way to return error responses with proper status codes.
What does FastAPI middleware use to execute code before and after each request?
Decorators like @app.before. The @app.middleware('http') decorator with call_next allows you to wrap request/response handling.
What should a complete CRUD API include?
Only GET and POST endpoints. CRUD stands for Create, Read, Update, Delete — POST, GET, PUT, DELETE respectively.
Why is logging important in production APIs?
It makes the code look better. Logging helps you debug production issues, monitor performance, and understand user behavior.
How do I create a User model with name (str, min_length=1) and email (str, min_length=5). Create a POST /users endpoint that accepts a User and returns a dictionary with id, name, email, and status='created'. Add a 422 error response to the OpenAPI documentation.?
When clients send invalid data to your API, FastAPI automatically validates it against your Pydantic models and returns a validation error response. By default, this response is a 422 Unprocessable Entity with detailed error information:
How do I create GET /items/{item_id} and DELETE /items/{item_id} endpoints. Both should raise HTTPException with status_code=404 if item_id is 999. Otherwise, return the item or deletion confirmation.?
Beyond validation errors, your API needs to handle application-specific errors like "item not found" or "insufficient permissions". FastAPI provides HTTPException for this:
How do I create a middleware that logs incoming requests. It should record the HTTP method, path, response status code, and processing time. Create a GET /items/{item_id} endpoint that returns an item and uses the logger.?
In production, you need to track what requests come in, how long they take, and what errors occur. FastAPI middleware lets you log requests and responses:
How do I build a complete API with CREATE (POST /items), READ (GET /items, GET /items/{item_id}), and LIST (GET /items) operations. Use ItemCreate for requests, Item for responses. Include proper error handling with HTTPException for missing items.?
Now it's time to build a complete REST API that combines everything: routing, request validation, response models, error handling, and logging.
In FastAPI, how do you define a path parameter?
Using a query string parameter. Path parameters are defined in the route path using curly braces, like @app.get('/users/{user_id}')
What type hint should you use for a path parameter that represents an ID?
str. Use int for numeric IDs. FastAPI will validate and reject non-integer values.
How do you make a query parameter optional with a default value?
Use Optional[str] without a default. Add a default value directly in the function signature: def search(query: str = 'default')
When you pass a Pydantic model as a function parameter in FastAPI, where does the data come from?
From path parameters. Pydantic models in function parameters are automatically treated as request bodies by FastAPI.
What happens when a client sends invalid data to a FastAPI endpoint?
The endpoint is called with the invalid data anyway. FastAPI automatically validates data and returns a 422 validation error response if data is invalid.
How do I create a FastAPI application with two endpoints. The GET / endpoint should return a dictionary with key 'message' and value 'Welcome to my API'. The GET /hello endpoint should return a dictionary with key 'greeting' and value 'Hello, FastAPI!'?
FastAPI is a modern Python web framework for building APIs. It uses type hints and Pydantic models to automatically validate incoming data and generate API documentation.
How do I create two endpoints. The first GET /items/{item_id} should return a dictionary with 'item_id' and 'name' fields. The name should be 'Item {item_id}'. The second GET /users/{user_id}/posts/{post_id} should return both IDs.?
Path parameters are parts of the URL that change based on the resource being requested. In FastAPI, you define them using curly braces in the route and add them as function parameters:
How do I create two endpoints. The first GET /search takes a required 'query' parameter and optional 'page' (default 1) and 'size' (default 10) query parameters. Return them in a dictionary. The second GET /products/{category} takes a path parameter and optional 'min_price' (default 0.0) and 'max_price' (default 1000.0) query parameters.?
Query parameters are optional or required values that appear after the `?` in a URL. For example, in `/items?skip=0&limit=10`, `skip` and `limit` are query parameters.
How do I create a User Pydantic model with fields username (str), email (str), and full_name (str). Create a POST /users endpoint that accepts a User request body and returns a dictionary with 'user', 'status' set to 'created', and 'id' set to 1.?
Path and query parameters work for small amounts of data, but when clients need to send complex structured data (like creating a new user or posting content), you use request bodies. FastAPI integrates seamlessly with Pydantic models to validate request bodies.
Why should you separate request models from response models?
It's not necessary. Request models validate client input, response models control what's returned. This separation provides security and flexibility.
What does the status_code parameter do in a route decorator?
It validates the response data. status_code sets the HTTP response code, like 201 for created, 200 for success, 404 for not found.
How do you create a nested model in Pydantic?
Use a dictionary as a field type. Use another BaseModel as a field type: class Post(BaseModel): author: User
What's the appropriate status code for a POST endpoint that creates a resource?
200. 201 Created is the standard status code for resource creation. 200 OK is for successful reads.
What happens when you return data that doesn't match the response_model?
The data is returned anyway. FastAPI validates your response against the response model and raises an error if it doesn't match.
How do I create a ProductCreate model with fields: name (str, min_length=1), description (optional string), and price (float, must be > 0). Create an endpoint that returns the product with id=1 and status='created'.?
A well-designed API separates the request input model from internal database models. This gives you flexibility to validate input separately from how data is stored.
How do I create an ItemResponse model with fields id (int), name (str), and price (float). Create a GET /items endpoint with response_model=List[ItemResponse] that returns a list of two items.?
Response models control exactly what data your API returns to clients. They also generate the OpenAPI documentation that describes your API.
How do I create nested models: Author (id: int, name: str) and Article (id: int, title: str, author: Author, tags: List[str]). Create a function that returns an article with nested author and tags.?
Real-world APIs often return nested structures where one entity contains others. For example, a blog post includes comments, and each comment includes user information.
How do I create an Item model with id (int) and name (str). Create a POST /items endpoint that returns status code 201 (Created) with response_model=Item. The endpoint should accept item_data and return the created item.?
APIs need to return different responses based on success or failure. FastAPI lets you specify multiple response models and set appropriate HTTP status codes.

Ready to write code?

Theory is just the start. Write real code, run tests, build the habit.

Open the playground →