Building REST APIs used to mean wrestling with boilerplate — decorators, route registration, request parsing, response serialization, validation errors. Then FastAPI arrived and changed the deal. With a handful of lines and standard Python type hints, you get a fully documented, auto-validated, production-grade API. Developers who switch from Flask or Django REST Framework often describe the experience as “typing less and getting more,” which is frankly a rare sensation in software engineering.

FastAPI is built on two libraries: Starlette for the ASGI web framework layer and Pydantic for data validation. Because it’s ASGI-native, it supports async request handling out of the box — so your API can handle thousands of concurrent connections without thread pool gymnastics. And because it uses type hints for everything, your editor knows what’s going on at every step, your tests can mock precisely, and your documentation generates itself.

In this tutorial you’ll build a complete book-collection REST API from the ground up. By the end you’ll have working endpoints for creating, reading, updating, and deleting books, proper validation that rejects garbage input, structured error responses, and interactive Swagger docs running live in your browser. We’ll also cover query parameters, path parameters, response models, and status codes — all the real-world pieces you need before going to production.

Quick Answer
Install FastAPI and Uvicorn (pip install fastapi uvicorn), define a Pydantic model, then decorate functions with @app.get(), @app.post(), @app.put(), @app.delete(). Run with uvicorn main:app --reload. FastAPI automatically validates requests, serializes responses, and generates /docs with interactive Swagger UI.
Developer celebrating as a FastAPI server starts up successfully
Zero config, full speed — main.py is live.

What Is FastAPI and Why Should You Use It?

FastAPI is a modern Python web framework designed specifically for building APIs fast — both in development speed and runtime performance. It was created by Sebastián Ramírez and released in 2018. In just a few years it became one of the most starred Python frameworks on GitHub, overtaking older contenders that had a decade head start.

The framework’s core trick is leveraging Python’s type annotation system for everything that used to require manual configuration. When you annotate a function parameter with int, FastAPI knows to parse it as an integer, reject strings, and document it as an integer in the API spec. When you annotate a return type with a Pydantic model, FastAPI knows exactly which fields to serialize and which to exclude. There’s no separate schema language, no XML config, no magical metaclasses — just Python types doing their job.

FeatureFastAPIFlaskDjango REST Framework
PerformanceVery high (ASGI)Moderate (WSGI)Moderate (WSGI)
Async supportNativeLimited (Quart)Partial (3.1+)
Auto validationYes (Pydantic)ManualSerializers
Auto docsYes (/docs, /redoc)NoYes (with extra setup)
Type hint integrationDeepNoneNone
Learning curveLowVery lowHigh

The performance story is real, not marketing. Because FastAPI uses ASGI and supports async/await, it can handle I/O-bound operations (database queries, external API calls, file reads) without blocking a thread for each request. Benchmarks consistently show FastAPI at or near the top of Python framework performance charts — comparable to Node.js and Go for I/O-heavy workloads.

Setting Up Your Environment

Before writing a single endpoint, get your environment sorted. Using a virtual environment keeps your project isolated from system Python and other projects.

# main.py
# Create and activate a virtual environment
python -m venv venv
source venv/bin/activate        # Windows: venv\Scripts\activate

# Install FastAPI and Uvicorn (the ASGI server)
pip install fastapi uvicorn[standard]

# Optional but recommended: install httpx for testing
pip install httpx pytest

Uvicorn is the ASGI server that actually runs your FastAPI application. The [standard] extra pulls in uvloop (faster event loop) and httptools (faster HTTP parsing) — it’s worth the slightly larger install for any real project. Create a project directory and a main.py file where we’ll build everything.

Your First FastAPI Application

Every FastAPI app starts with the same three lines: import the library, create an app instance, and define at least one route. Here’s the absolute minimum to get something running:

# main.py
from fastapi import FastAPI

app = FastAPI(
    title="Book Collection API",
    description="A REST API for managing your book collection",
    version="1.0.0"
)

@app.get("/")
def read_root():
    return {"message": "Welcome to the Book Collection API"}

Save this as main.py and run it:

uvicorn main:app --reload

The --reload flag makes Uvicorn watch your files and restart automatically when you save changes — essential during development. Visit http://127.0.0.1:8000 and you’ll see your JSON response. Now visit http://127.0.0.1:8000/docs — that’s your free Swagger UI, already documenting your endpoint. Visit http://127.0.0.1:8000/redoc for an alternative ReDoc interface. Both update automatically as you add endpoints.

Developer reviewing auto-generated API documentation blueprints
Auto-generated docs: because writing them manually is for masochists

Defining Data Models with Pydantic

Pydantic models are the backbone of FastAPI’s validation system. You define a class that inherits from BaseModel, annotate its fields with types, and FastAPI handles the rest — parsing incoming JSON, validating types and constraints, and returning structured error messages when something’s wrong.

# models.py
from pydantic import BaseModel, Field
from typing import Optional
from datetime import date

class BookBase(BaseModel):
    title: str = Field(..., min_length=1, max_length=200, description="The book's title")
    author: str = Field(..., min_length=1, max_length=100, description="Author's full name")
    isbn: Optional[str] = Field(None, pattern=r"^\d{13}$", description="13-digit ISBN")
    published_date: Optional[date] = None
    genre: str = Field(..., description="Genre category")
    pages: int = Field(..., gt=0, lt=10000, description="Number of pages")
    rating: Optional[float] = Field(None, ge=0.0, le=5.0, description="Rating 0-5")

class BookCreate(BookBase):
    pass  # Same as BookBase, used for POST requests

class BookUpdate(BaseModel):
    title: Optional[str] = Field(None, min_length=1, max_length=200)
    author: Optional[str] = Field(None, min_length=1, max_length=100)
    genre: Optional[str] = None
    pages: Optional[int] = Field(None, gt=0, lt=10000)
    rating: Optional[float] = Field(None, ge=0.0, le=5.0)

class Book(BookBase):
    id: int
    created_at: date

    class Config:
        from_attributes = True  # Pydantic V2: enables ORM mode

The Field() function adds validation constraints and documentation metadata. ... as the first argument means the field is required. gt=0 means “greater than zero,” le=5.0 means “less than or equal to 5.0.” If an incoming request sends pages: -1, FastAPI will return a 422 Unprocessable Entity response with a clear explanation — no validation code from you required.

The three-model pattern (BookBase, BookCreate, Book) is a FastAPI convention worth adopting early. BookCreate is what clients send in POST requests. Book is what you return — it includes server-generated fields like id and created_at that clients don’t provide. BookUpdate uses all-Optional fields so clients can send partial updates.

Building the In-Memory Data Store

For this tutorial, we’ll use a Python dictionary as our “database.” This lets us focus on FastAPI patterns without database setup. In a real application you’d swap this for SQLAlchemy, SQLModel, or an async ORM — the endpoint logic barely changes.

# main.py
from datetime import date

# Simulated database
books_db: dict[int, dict] = {}
next_id: int = 1

def get_next_id() -> int:
    global next_id
    current = next_id
    next_id += 1
    return current

# Pre-populate with sample data
sample_books = [
    {"title": "Fluent Python", "author": "Luciano Ramalho", "genre": "Programming",
     "pages": 792, "rating": 4.8, "isbn": "9781492056355", "published_date": date(2022, 3, 1)},
    {"title": "Clean Code", "author": "Robert C. Martin", "genre": "Programming",
     "pages": 431, "rating": 4.3, "isbn": "9780132350884", "published_date": date(2008, 8, 1)},
    {"title": "The Pragmatic Programmer", "author": "David Thomas", "genre": "Programming",
     "pages": 352, "rating": 4.6, "isbn": "9780135957059", "published_date": date(2019, 9, 1)},
]

for book_data in sample_books:
    book_id = get_next_id()
    books_db[book_id] = {"id": book_id, "created_at": date.today(), **book_data}

The dict[int, dict] type annotation (Python 3.9+) tells your editor and colleagues exactly what this structure is. Nothing worse than inheriting a codebase full of untyped dictionaries — be kind to future-you.

Programmer managing a chain of interconnected dependencies
When your dependency injection actually makes sense

Implementing GET Endpoints

GET endpoints retrieve data. FastAPI makes the distinction between path parameters (in the URL path itself) and query parameters (after the ?) completely automatic based on your function signature.

# main.py
from fastapi import FastAPI, HTTPException, Query, status
from typing import Optional

@app.get("/books", response_model=list[Book])
def list_books(
    genre: Optional[str] = Query(None, description="Filter by genre"),
    min_rating: Optional[float] = Query(None, ge=0.0, le=5.0, description="Minimum rating"),
    skip: int = Query(0, ge=0, description="Number of books to skip"),
    limit: int = Query(10, ge=1, le=100, description="Maximum books to return"),
):
    """Return a paginated list of books, optionally filtered."""
    books = list(books_db.values())

    if genre:
        books = [b for b in books if b.get("genre", "").lower() == genre.lower()]

    if min_rating is not None:
        books = [b for b in books if (b.get("rating") or 0) >= min_rating]

    return books[skip : skip + limit]


@app.get("/books/{book_id}", response_model=Book)
def get_book(book_id: int):
    """Return a single book by ID."""
    if book_id not in books_db:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Book with ID {book_id} not found"
        )
    return books_db[book_id]

Notice how genre, min_rating, skip, and limit are all function parameters with defaults — FastAPI automatically treats them as query parameters. book_id in get_book matches the {book_id} placeholder in the route, making it a path parameter. The response_model=Book argument tells FastAPI to serialize the return value through the Book Pydantic model, filtering out any fields not in the model and validating the output.

The HTTPException is FastAPI’s way of returning error responses. Raise it with a status code and detail message — FastAPI converts it to a properly formatted JSON error response automatically.

Implementing POST Endpoints

POST endpoints create new resources. The request body is where clients send the data, and Pydantic handles all the parsing and validation:

# main.py
@app.post("/books", response_model=Book, status_code=status.HTTP_201_CREATED)
def create_book(book: BookCreate):
    """Create a new book in the collection."""
    book_id = get_next_id()
    book_data = {
        "id": book_id,
        "created_at": date.today(),
        **book.model_dump()  # Pydantic V2: converts model to dict
    }
    books_db[book_id] = book_data
    return book_data

The book: BookCreate parameter tells FastAPI to parse the request body as a BookCreate model. If the client sends invalid JSON, missing required fields, or fields with wrong types, FastAPI returns a 422 with detailed field-level error messages. You write zero validation code. The status_code=201 sets the success response status — POST creating a resource should return 201 Created, not the default 200 OK.

Implementing PUT and PATCH Endpoints

PUT typically replaces the entire resource; PATCH applies a partial update. FastAPI handles both the same way — the difference is in your model design and what you do with the data:

# main.py
@app.put("/books/{book_id}", response_model=Book)
def update_book(book_id: int, book_update: BookUpdate):
    """Update an existing book's details (partial update)."""
    if book_id not in books_db:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Book with ID {book_id} not found"
        )

    # Get only the fields that were actually sent (exclude None values)
    update_data = book_update.model_dump(exclude_unset=True)

    if not update_data:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="No fields provided for update"
        )

    books_db[book_id].update(update_data)
    return books_db[book_id]

The exclude_unset=True argument to model_dump() is critical for partial updates. Without it, all optional fields default to None and you’d overwrite existing data with null values whenever a client sends a partial update. With it, only the fields the client explicitly sent are included in the update dictionary. This is the correct behavior for a PATCH/PUT endpoint that supports partial updates.

Developer watching test suite pass with green checkmarks everywhere
Green tests: the closest thing to job security

Implementing DELETE Endpoints

A DELETE endpoint removes a resource from the server. Unlike GET or POST, a successful delete doesn’t need to return the deleted object — the standard practice is to return HTTP 204 No Content, which tells the client “it worked, and there’s nothing to send back.” We also want to raise a 404 if the caller tries to delete a book that doesn’t exist.

# main.py
@app.delete("/books/{book_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_book(book_id: int):
    """Remove a book from the collection."""
    if book_id not in books_db:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Book with ID {book_id} not found"
        )
    del books_db[book_id]
    # 204 No Content: return nothing

DELETE responses use 204 No Content by convention — the resource is gone, there’s nothing to return. FastAPI handles this correctly when you set status_code=204; don’t return anything from the function (or return None).

Adding Search and Advanced Query Parameters

Real APIs need search. Let’s add a proper search endpoint that queries across multiple fields:

# main.py
@app.get("/books/search/", response_model=list[Book])
def search_books(
    q: str = Query(..., min_length=2, description="Search term"),
    fields: list[str] = Query(
        default=["title", "author"],
        description="Fields to search in"
    ),
):
    """Full-text search across book fields."""
    valid_fields = {"title", "author", "genre", "isbn"}
    search_fields = [f for f in fields if f in valid_fields]

    if not search_fields:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Invalid search fields. Valid options: {valid_fields}"
        )

    q_lower = q.lower()
    results = []
    for book in books_db.values():
        for field in search_fields:
            value = str(book.get(field, "")).lower()
            if q_lower in value:
                results.append(book)
                break  # Don't add same book twice

    return results

FastAPI supports list query parameters using Query(default=[...]). Clients can pass multiple values with repeated parameters: /books/search/?q=python&fields=title&fields=author. This is RESTful design done right — no custom query language, just standard HTTP.

Structured Error Responses

FastAPI’s default error responses are good, but production APIs often need custom error formats that match a specific schema. You can customize the exception handlers:

# main.py
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 validation errors in a consistent format."""
    errors = []
    for error in exc.errors():
        errors.append({
            "field": ".".join(str(loc) for loc in error["loc"]),
            "message": error["msg"],
            "type": error["type"]
        })
    return JSONResponse(
        status_code=422,
        content={
            "status": "error",
            "message": "Validation failed",
            "errors": errors
        }
    )

@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
    """Return HTTP errors in a consistent format."""
    return JSONResponse(
        status_code=exc.status_code,
        content={
            "status": "error",
            "message": exc.detail,
            "code": exc.status_code
        }
    )

Custom exception handlers intercept errors before they reach the client and let you reshape the response. This is how you ensure your entire API speaks the same error dialect — frontend developers appreciate APIs that are consistent about errors as much as they appreciate consistent success responses.

Dependency Injection: A FastAPI Superpower

FastAPI has a built-in dependency injection system that lets you extract common logic into reusable “dependencies.” A classic use case is pagination parameters that appear on every list endpoint:

# dependencies.py
from fastapi import Depends

class PaginationParams:
    def __init__(
        self,
        skip: int = Query(0, ge=0, description="Items to skip"),
        limit: int = Query(10, ge=1, le=100, description="Max items to return")
    ):
        self.skip = skip
        self.limit = limit

def get_pagination(pagination: PaginationParams = Depends()) -> PaginationParams:
    return pagination

# Now any endpoint can use pagination with one parameter
@app.get("/books/genre/{genre}", response_model=list[Book])
def books_by_genre(
    genre: str,
    pagination: PaginationParams = Depends(get_pagination)
):
    """Get books filtered by genre with pagination."""
    matching = [
        b for b in books_db.values()
        if b.get("genre", "").lower() == genre.lower()
    ]
    return matching[pagination.skip : pagination.skip + pagination.limit]

Dependencies can depend on other dependencies, creating a clean hierarchy. They’re also the right place for authentication checks, database session management, and rate limiting — all the cross-cutting concerns that shouldn’t live inside your business logic functions.

Putting It All Together: The Complete API

Here’s your complete main.py with all the pieces integrated:

# main.py
from fastapi import FastAPI, HTTPException, Query, Depends, Request, status
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from pydantic import BaseModel, Field
from typing import Optional
from datetime import date

app = FastAPI(
    title="Book Collection API",
    description="A complete REST API for managing your book collection",
    version="1.0.0",
    docs_url="/docs",
    redoc_url="/redoc"
)

# --- Models ---
class BookBase(BaseModel):
    title: str = Field(..., min_length=1, max_length=200)
    author: str = Field(..., min_length=1, max_length=100)
    isbn: Optional[str] = Field(None, pattern=r"^\d{13}$")
    published_date: Optional[date] = None
    genre: str
    pages: int = Field(..., gt=0, lt=10000)
    rating: Optional[float] = Field(None, ge=0.0, le=5.0)

class BookCreate(BookBase):
    pass

class BookUpdate(BaseModel):
    title: Optional[str] = Field(None, min_length=1, max_length=200)
    author: Optional[str] = Field(None, min_length=1, max_length=100)
    genre: Optional[str] = None
    pages: Optional[int] = Field(None, gt=0, lt=10000)
    rating: Optional[float] = Field(None, ge=0.0, le=5.0)

class Book(BookBase):
    id: int
    created_at: date

# --- Data store ---
books_db: dict[int, dict] = {}
_next_id = 1

def get_next_id():
    global _next_id
    i = _next_id; _next_id += 1; return i

# Seed data
for d in [
    {"title": "Fluent Python", "author": "Luciano Ramalho", "genre": "Programming",
     "pages": 792, "rating": 4.8, "isbn": "9781492056355", "published_date": date(2022, 3, 1)},
    {"title": "Clean Code", "author": "Robert C. Martin", "genre": "Programming",
     "pages": 431, "rating": 4.3, "isbn": "9780132350884", "published_date": date(2008, 8, 1)},
]:
    bid = get_next_id()
    books_db[bid] = {"id": bid, "created_at": date.today(), **d}

# --- Exception handlers ---
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
    return JSONResponse(status_code=422, content={
        "status": "error", "message": "Validation failed",
        "errors": [{"field": ".".join(str(l) for l in e["loc"]),
                    "message": e["msg"], "type": e["type"]} for e in exc.errors()]
    })

# --- Pagination dependency ---
class PaginationParams:
    def __init__(self, skip: int = Query(0, ge=0), limit: int = Query(10, ge=1, le=100)):
        self.skip = skip; self.limit = limit

# --- Endpoints ---
@app.get("/", tags=["Health"])
def root(): return {"status": "ok", "api": "Book Collection API v1.0.0"}

@app.get("/books", response_model=list[Book], tags=["Books"])
def list_books(genre: Optional[str] = None, min_rating: Optional[float] = Query(None, ge=0, le=5),
               p: PaginationParams = Depends()):
    books = list(books_db.values())
    if genre: books = [b for b in books if b.get("genre","").lower() == genre.lower()]
    if min_rating is not None: books = [b for b in books if (b.get("rating") or 0) >= min_rating]
    return books[p.skip:p.skip+p.limit]

@app.get("/books/search/", response_model=list[Book], tags=["Books"])
def search_books(q: str = Query(..., min_length=2)):
    q_l = q.lower()
    return [b for b in books_db.values()
            if q_l in b.get("title","").lower() or q_l in b.get("author","").lower()]

@app.get("/books/{book_id}", response_model=Book, tags=["Books"])
def get_book(book_id: int):
    if book_id not in books_db:
        raise HTTPException(status.HTTP_404_NOT_FOUND, f"Book {book_id} not found")
    return books_db[book_id]

@app.post("/books", response_model=Book, status_code=201, tags=["Books"])
def create_book(book: BookCreate):
    bid = get_next_id()
    books_db[bid] = {"id": bid, "created_at": date.today(), **book.model_dump()}
    return books_db[bid]

@app.put("/books/{book_id}", response_model=Book, tags=["Books"])
def update_book(book_id: int, book_update: BookUpdate):
    if book_id not in books_db:
        raise HTTPException(status.HTTP_404_NOT_FOUND, f"Book {book_id} not found")
    update_data = book_update.model_dump(exclude_unset=True)
    if not update_data:
        raise HTTPException(400, "No fields to update")
    books_db[book_id].update(update_data)
    return books_db[book_id]

@app.delete("/books/{book_id}", status_code=204, tags=["Books"])
def delete_book(book_id: int):
    if book_id not in books_db:
        raise HTTPException(status.HTTP_404_NOT_FOUND, f"Book {book_id} not found")
    del books_db[book_id]

Testing Your API

FastAPI’s TestClient wraps your app for synchronous testing without needing a running server:

# test_app.py
from fastapi.testclient import TestClient
from main import app

client = TestClient(app)

def test_create_and_retrieve_book():
    # Create a new book
    response = client.post("/books", json={
        "title": "Python Cookbook",
        "author": "David Beazley",
        "genre": "Programming",
        "pages": 706,
        "rating": 4.7
    })
    assert response.status_code == 201
    created = response.json()
    assert created["title"] == "Python Cookbook"
    assert "id" in created

    # Retrieve the book by ID
    book_id = created["id"]
    response = client.get(f"/books/{book_id}")
    assert response.status_code == 200
    assert response.json()["title"] == "Python Cookbook"

def test_validation_rejects_bad_data():
    response = client.post("/books", json={
        "title": "",           # Too short
        "author": "Someone",
        "genre": "Programming",
        "pages": -5,           # Fails gt=0 constraint
    })
    assert response.status_code == 422

def test_404_on_missing_book():
    response = client.get("/books/999999")
    assert response.status_code == 404

def test_partial_update():
    # First create
    create_resp = client.post("/books", json={
        "title": "Test Book", "author": "Author", "genre": "Fiction", "pages": 100
    })
    book_id = create_resp.json()["id"]

    # Update only rating
    update_resp = client.put(f"/books/{book_id}", json={"rating": 4.5})
    assert update_resp.status_code == 200
    assert update_resp.json()["rating"] == 4.5
    assert update_resp.json()["title"] == "Test Book"  # Unchanged

Run the tests with pytest test_main.py -v. FastAPI’s test client handles all the HTTP mechanics, so your tests read like plain assertions about inputs and outputs. No mocking required for basic endpoint testing.

Real-Life Example: A Task Manager API

Here’s a more complete real-world scenario: a task manager API that demonstrates how these patterns work together for a domain with more business logic.

# real_life_project.py
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field
from typing import Optional
from datetime import date, datetime
from enum import Enum

class Priority(str, Enum):
    low = "low"
    medium = "medium"
    high = "high"
    critical = "critical"

class TaskStatus(str, Enum):
    todo = "todo"
    in_progress = "in_progress"
    done = "done"
    cancelled = "cancelled"

class TaskCreate(BaseModel):
    title: str = Field(..., min_length=1, max_length=255)
    description: Optional[str] = None
    priority: Priority = Priority.medium
    due_date: Optional[date] = None
    assignee: Optional[str] = None

class Task(TaskCreate):
    id: int
    status: TaskStatus = TaskStatus.todo
    created_at: datetime
    updated_at: datetime

class TaskStatusUpdate(BaseModel):
    status: TaskStatus

app = FastAPI(title="Task Manager API", version="1.0.0")
tasks_db: dict[int, dict] = {}
_tid = 1

def new_task_id():
    global _tid; tid = _tid; _tid += 1; return tid

@app.post("/tasks", response_model=Task, status_code=201)
def create_task(task: TaskCreate):
    now = datetime.now()
    tid = new_task_id()
    tasks_db[tid] = {
        "id": tid,
        "status": TaskStatus.todo,
        "created_at": now,
        "updated_at": now,
        **task.model_dump()
    }
    return tasks_db[tid]

@app.patch("/tasks/{task_id}/status", response_model=Task)
def update_task_status(task_id: int, update: TaskStatusUpdate):
    if task_id not in tasks_db:
        raise HTTPException(status.HTTP_404_NOT_FOUND, f"Task {task_id} not found")

    task = tasks_db[task_id]

    # Business rule: can't un-cancel a task
    if task["status"] == TaskStatus.cancelled and update.status != TaskStatus.cancelled:
        raise HTTPException(status.HTTP_409_CONFLICT, "Cannot reopen a cancelled task")

    task["status"] = update.status
    task["updated_at"] = datetime.now()
    return task

@app.get("/tasks", response_model=list[Task])
def list_tasks(
    status: Optional[TaskStatus] = None,
    priority: Optional[Priority] = None,
    assignee: Optional[str] = None
):
    tasks = list(tasks_db.values())
    if status: tasks = [t for t in tasks if t["status"] == status]
    if priority: tasks = [t for t in tasks if t["priority"] == priority]
    if assignee: tasks = [t for t in tasks if t.get("assignee") == assignee]
    return tasks

The Enum classes for Priority and TaskStatus give you two things for free: validation (FastAPI rejects any value not in the enum) and documentation (Swagger shows the valid options as a dropdown). The business rule about not reopening cancelled tasks lives in the endpoint function — which is exactly the right place for domain logic that doesn’t belong in the model.

Preparing for Production

Before deploying, several things need attention. Routing order matters — FastAPI matches routes top-to-bottom, so a route like /books/search/ must be registered before /books/{book_id}, or “search” will be interpreted as a book_id. In our complete example above the search route comes first; this is intentional.

Add CORS middleware if your API will be consumed by a browser frontend:

# main.py
from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://yourfrontend.com"],  # Replace with actual origins
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

For production deployment, run Uvicorn with multiple workers:

# Run with 4 workers (typical for a 4-core machine)
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4

# Or use Gunicorn with Uvicorn workers (recommended for production)
pip install gunicorn
gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000

For a real database, add sqlalchemy and asyncpg (for async PostgreSQL), or use SQLModel — which was also built by the FastAPI author and integrates cleanly with both FastAPI and SQLAlchemy.

Frequently Asked Questions

Do I need to use async functions in FastAPI?
No — FastAPI works with both regular (def) and async (async def) functions. Use async def when your endpoint does I/O (database queries, external HTTP calls, file operations) and you want it to be non-blocking. Use regular def for CPU-bound or simple endpoints. FastAPI runs regular functions in a thread pool automatically, so you won’t block the event loop either way.

How do I add authentication to a FastAPI API?
The cleanest approach is using FastAPI’s dependency injection with OAuth2PasswordBearer for JWT token auth. Define a get_current_user dependency that extracts and validates the token, then add it as a dependency to protected endpoints. We cover this in detail in our FastAPI Authentication with OAuth2 and JWT tutorial.

How do I connect FastAPI to a real database?
Use SQLModel (from the FastAPI author) for the smoothest experience, or SQLAlchemy with asyncpg for async PostgreSQL. Create a database session dependency, inject it into your endpoints, and use the ORM for queries instead of the in-memory dict.

What’s the difference between PUT and PATCH in REST APIs?
By convention, PUT replaces the entire resource (all fields), while PATCH applies a partial update (only specified fields). FastAPI doesn’t enforce this distinction — it’s up to you to implement the correct behavior. The model_dump(exclude_unset=True) pattern shown above is the standard approach for PATCH semantics.

Can FastAPI handle file uploads?
Yes — use the UploadFile type from FastAPI for file upload endpoints. Add python-multipart to handle multipart form data (pip install python-multipart), then accept file: UploadFile as a parameter in your endpoint function.

How do I version a FastAPI API?
The cleanest approach is path prefixes: /api/v1/books, /api/v2/books. Use FastAPI’s APIRouter to define routes for each version in separate files, then include them in the main app with app.include_router(v1_router, prefix="/api/v1").

Summary

FastAPI gives you a complete REST API toolkit in one package: automatic request parsing via Pydantic, automatic response serialization, automatic validation with informative error messages, and automatic interactive documentation. The patterns you’ve learned here — the three-model approach (Create/Update/Response), dependency injection for shared logic, structured exception handlers, and testing with TestClient — scale from toy examples to production systems without fundamental changes.

The next steps are adding a real database, authentication, and background tasks. Check out our related tutorials: FastAPI vs Flask: Which Framework Should You Choose?, FastAPI Authentication with OAuth2 and JWT, and Pydantic V2 Data Validation for the full picture.