Home Programming Building REST APIs with FastAPI: A Modern Python Web Framework Guide

Building REST APIs with FastAPI: A Modern Python Web Framework Guide

Last updated: May 27, 2026
k
Published April 4, 2026 · Updated May 27, 2026 · 34 min read

This post examines FastAPI in 2026 and demonstrates how to construct a production-ready REST API from scratch. In December 2018, a Colombian developer named Sebastián Ramírez pushed the first commit of a Python web framework to GitHub. Six years later, that project — FastAPI — has surpassed 80,000 stars, overtaken Flask in monthly downloads, and become the framework of choice at Netflix, Uber, Microsoft, and hundreds of startups building production APIs. The questions that arise are clear: what makes FastAPI so compelling that companies are rewriting entire API layers around it, and how can its capabilities be applied to build robust, production-ready REST APIs?

For anyone familiar with the Python web ecosystem, the landscape has been dominated by two heavyweights for more than a decade: Flask, the minimalist micro-framework valued for its simplicity, and Django with its REST Framework, the batteries-included monolith favoured by enterprises. Both are excellent tools. They were designed, however, in a world before type hints became standard, before async was a first-class citizen in Python, and before API-first architectures became the default approach to building software.

FastAPI was created in a different environment. It leverages modern Python features that make Python one of the most productive languages available today — type annotations, async/await, and Pydantic data validation — to deliver something that approaches a transformation in developer experience: ordinary annotated Python functions are written, and the framework automatically generates interactive API documentation, validates every request and response, and runs with performance that rivals Node.js and Go. This is not marketing rhetoric. Independent benchmarks consistently show FastAPI handling 2–5x more requests per second than Flask.

This guide builds a complete REST API from zero to deployment. By the end, the reader will possess a fully functional task-management API with CRUD operations, database persistence, authentication, tests, and a production deployment strategy. Every code example is complete and runnable, permitting the reader to follow along step by step and conclude with a working API.

The discussion follows.

FastAPI Request-Response Lifecycle Client Browser / App HTTP FastAPI Routing + Validation Parsed Path Operation Your Python Function + Dependencies Result Response JSON + Status Code Response travels back to client

Summary

What this post covers: A zero-to-deployment FastAPI tutorial that builds a complete task-manager REST API with CRUD endpoints, Pydantic validation, SQLAlchemy persistence, JWT authentication, tests, and a production deployment strategy.

Key insights:

  • FastAPI’s appeal is structural, not cosmetic—type hints + Pydantic + ASGI/Starlette give you automatic OpenAPI docs, request/response validation, and async I/O from the same function signature you would have written anyway.
  • Independent benchmarks show FastAPI handling 2–5x more requests per second than Flask, putting it in the same performance class as Node.js and Go for typical I/O-bound workloads.
  • Use Pydantic models as the single source of truth for request bodies, response shapes, and OpenAPI schema—if you find yourself duplicating field definitions between models and SQLAlchemy tables, you are doing it wrong.
  • Authentication is best implemented with FastAPI’s Depends() system: a single get_current_user dependency injected into protected routes keeps JWT decoding, expiry checks, and DB lookups out of your endpoint code.
  • For production, the right stack is Uvicorn (or Gunicorn with Uvicorn workers) behind Nginx, with structured logging, CORS configured explicitly per origin, and tests written against TestClient so they exercise the real ASGI app, not a mock.

Main topics: Why FastAPI, Setting Up Your Environment, Your First API—Hello World, Building a Complete CRUD API—Task Manager, Request Validation and Pydantic Models, Path Parameters Query Parameters and Request Body, Adding a Database with SQLAlchemy, Authentication and Security, Middleware CORS and Error Handling, Testing Your API, Deployment, Best Practices.

Why FastAPI?

Before any code is written, the characteristics that distinguish FastAPI and explain its rapid adoption in the Python community warrant examination.

Automatic OpenAPI and Swagger Documentation

Every FastAPI application automatically generates an OpenAPI schema and serves an interactive Swagger UI at /docs and a ReDoc interface at /redoc. No plugins must be installed, no YAML files written, and no separate documentation maintained. The code is the documentation, and the two are always in sync.

Type Hints and Pydantic Validation

FastAPI is built on top of Pydantic, the most widely used data-validation library in Python. Request and response models are defined as simple Python classes with type annotations, and FastAPI automatically validates incoming data, serialises outgoing data, and generates accurate schema documentation — all from the same model definition.

Native Async Support

FastAPI natively supports Python’s async/await syntax. This permits the API to handle thousands of concurrent connections efficiently without blocking, which is critical for I/O-bound workloads such as database queries, external API calls, and file operations. Regular synchronous functions are also supported; FastAPI handles both seamlessly.

Performance Comparable to Node.js and Go

Owing to its ASGI foundation (powered by Starlette) and the Uvicorn server, FastAPI delivers exceptional performance. In the TechEmpower Web Framework Benchmarks, Python ASGI frameworks consistently outperform traditional WSGI frameworks by significant margins.

Framework Comparison

Feature FastAPI Flask Django REST Express.js
Auto Documentation Built-in Plugin required Plugin required Plugin required
Data Validation Built-in (Pydantic) Manual / Marshmallow Built-in (Serializers) Manual / Joi
Async Support Native Limited Django 4.1+ Native
Performance (req/s) ~15,000+ ~3,000 ~2,500 ~18,000+
Learning Curve Easy Very Easy Moderate Easy
Type Safety Full (type hints) None Partial TypeScript optional
Dependency Injection Built-in No No No

 

Key Takeaway: FastAPI provides the simplicity of Flask, the features of Django REST Framework, and performance that approaches Node.js — all in one package. For any new Python API project in 2026, FastAPI is the appropriate default choice.

FastAPI Architecture Layers Routes (Path Operations) @app.get(“/tasks”) @app.post(“/tasks”) @app.put(“/tasks/{id}”) @app.delete(“/tasks/{id}”) Dependencies (Dependency Injection) Auth verification · DB session · Rate limiting · Request parsing Services (Business Logic) Validation rules · Data transformation · Error handling · Domain logic Database (SQLAlchemy / ORM)

Setting Up Your Environment

A clean development environment is the appropriate starting point. The discussion uses Python 3.11+ (though 3.9+ is also acceptable) and creates an isolated virtual environment for the project.

Verify the Python Installation

python3 --version
# Python 3.11.x or higher recommended

Create the Project Directory

mkdir fastapi-task-manager
cd fastapi-task-manager

Set Up a Virtual Environment

Two options are available. The classic venv approach is one:

# Option 1: Classic venv
python3 -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate

# Option 2: Using uv (much faster)
pip install uv
uv venv
source .venv/bin/activate
Tip: Anyone unfamiliar with uv should consider trying it. It is a Rust-based Python package manager that installs dependencies 10–100x faster than pip and is rapidly becoming the standard tool for Python project management.

Install FastAPI and Uvicorn

# Install FastAPI with all optional dependencies
pip install "fastapi[standard]"

# This installs:
# - fastapi (the framework)
# - uvicorn (the ASGI server)
# - pydantic (data validation)
# - starlette (the underlying ASGI toolkit)
# - httpx (for testing)
# - python-multipart (for form data)
# - jinja2 (for templates, if needed)

Project Structure

A clean project structure that will scale as the API grows is appropriate from the outset:

fastapi-task-manager/
├── app/
│   ├── __init__.py
│   ├── main.py            # FastAPI app entry point
│   ├── models.py           # Pydantic models (schemas)
│   ├── database.py         # Database configuration
│   ├── crud.py             # Database operations
│   ├── auth.py             # Authentication logic
│   └── routers/
│       ├── __init__.py
│       └── tasks.py        # Task endpoints
├── tests/
│   ├── __init__.py
│   └── test_tasks.py       # API tests
├── requirements.txt
├── Dockerfile
└── .env

Create the initial directory structure:

mkdir -p app/routers tests
touch app/__init__.py app/routers/__init__.py tests/__init__.py

A First API: Hello World

The most direct illustration begins with the simplest possible FastAPI application. The framework’s behaviour can then be observed.

Create app/main.py:

from fastapi import FastAPI

app = FastAPI(
    title="Task Manager API",
    description="A complete REST API for managing tasks",
    version="1.0.0",
)


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


@app.get("/health")
def health_check():
    return {"status": "healthy"}

That is the entire requirement. Seven lines of actual code produce a working API with two endpoints. The application is run as follows:

uvicorn app.main:app --reload --host 0.0.0.0 --port 8000

The --reload flag enables hot reloading, so the server restarts automatically when code is changed. Output of the following form should appear:

INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [12345]
INFO:     Started server process [12346]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

Exploring the Swagger UI

Opening a browser at http://localhost:8000/docs reveals an attractive interactive API documentation page, generated entirely from the code. Any endpoint may be clicked, “Try it out” selected, and requests executed directly from the browser.

The alternative documentation layout is available at http://localhost:8000/redoc, and the raw OpenAPI schema — importable into Postman, Insomnia, or any API client — is available at http://localhost:8000/openapi.json.

Key Takeaway: No documentation code has been written, yet a fully interactive API explorer is available. This is one of FastAPI’s distinguishing features: code and documentation are always in sync because they are the same artefact.

Building a Complete CRUD API: Task Manager

The following section constructs a substantive example: a full task-management API with all CRUD operations, proper validation, error handling, and correct HTTP status codes. The discussion begins with in-memory storage to focus on API design, and a database is added later.

REST API HTTP Methods Method Endpoint Action Status Code GET /tasks /tasks/{id} Read (list or single) 200 OK POST /tasks Create new resource 201 Created PUT /tasks/{id} Replace full resource 200 OK DELETE /tasks/{id} Remove resource 204 No Content

Define Pydantic Models

The first step is to define the data models. Create app/models.py:

from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
from enum import Enum


class TaskStatus(str, Enum):
    pending = "pending"
    in_progress = "in_progress"
    completed = "completed"
    cancelled = "cancelled"


class TaskCreate(BaseModel):
    title: str = Field(
        ...,
        min_length=1,
        max_length=200,
        description="The title of the task",
        examples=["Buy groceries"],
    )
    description: Optional[str] = Field(
        None,
        max_length=2000,
        description="Detailed description of the task",
    )
    status: TaskStatus = Field(
        default=TaskStatus.pending,
        description="Current status of the task",
    )
    priority: int = Field(
        default=1,
        ge=1,
        le=5,
        description="Priority level from 1 (lowest) to 5 (highest)",
    )


class TaskUpdate(BaseModel):
    title: Optional[str] = Field(
        None,
        min_length=1,
        max_length=200,
    )
    description: Optional[str] = Field(None, max_length=2000)
    status: Optional[TaskStatus] = None
    priority: Optional[int] = Field(None, ge=1, le=5)


class TaskResponse(BaseModel):
    id: int
    title: str
    description: Optional[str] = None
    status: TaskStatus
    priority: int
    created_at: datetime
    updated_at: datetime

The separation of concerns is important: TaskCreate represents what clients send when creating a task, TaskUpdate allows partial updates (all fields optional), and TaskResponse represents what the API returns. This is a critical design pattern; the internal data model should never be exposed directly.

Build the CRUD Endpoints

The actual API can now be built. Update app/main.py:

from fastapi import FastAPI, HTTPException, Query
from typing import Optional
from datetime import datetime

from app.models import TaskCreate, TaskUpdate, TaskResponse, TaskStatus

app = FastAPI(
    title="Task Manager API",
    description="A complete REST API for managing tasks",
    version="1.0.0",
)

# In-memory storage
tasks_db: dict[int, dict] = {}
task_id_counter = 0


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


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


@app.get("/tasks", response_model=list[TaskResponse])
def list_tasks(
    status: Optional[TaskStatus] = Query(
        None, description="Filter tasks by status"
    ),
    priority: Optional[int] = Query(
        None, ge=1, le=5, description="Filter tasks by priority"
    ),
    skip: int = Query(0, ge=0, description="Number of tasks to skip"),
    limit: int = Query(
        20, ge=1, le=100, description="Maximum number of tasks to return"
    ),
):
    """Retrieve all tasks with optional filtering and pagination."""
    results = list(tasks_db.values())

    # Apply filters
    if status is not None:
        results = [t for t in results if t["status"] == status]
    if priority is not None:
        results = [t for t in results if t["priority"] == priority]

    # Apply pagination
    return results[skip : skip + limit]


@app.get("/tasks/{task_id}", response_model=TaskResponse)
def get_task(task_id: int):
    """Retrieve a single task by its ID."""
    if task_id not in tasks_db:
        raise HTTPException(
            status_code=404,
            detail=f"Task with ID {task_id} not found",
        )
    return tasks_db[task_id]


@app.post("/tasks", response_model=TaskResponse, status_code=201)
def create_task(task: TaskCreate):
    """Create a new task."""
    now = datetime.utcnow()
    task_id = get_next_id()

    task_data = {
        "id": task_id,
        "title": task.title,
        "description": task.description,
        "status": task.status,
        "priority": task.priority,
        "created_at": now,
        "updated_at": now,
    }
    tasks_db[task_id] = task_data
    return task_data


@app.put("/tasks/{task_id}", response_model=TaskResponse)
def update_task(task_id: int, task_update: TaskUpdate):
    """Update an existing task. Only provided fields will be updated."""
    if task_id not in tasks_db:
        raise HTTPException(
            status_code=404,
            detail=f"Task with ID {task_id} not found",
        )

    existing_task = tasks_db[task_id]
    update_data = task_update.model_dump(exclude_unset=True)

    for field, value in update_data.items():
        existing_task[field] = value

    existing_task["updated_at"] = datetime.utcnow()
    return existing_task


@app.delete("/tasks/{task_id}", status_code=204)
def delete_task(task_id: int):
    """Delete a task by its ID."""
    if task_id not in tasks_db:
        raise HTTPException(
            status_code=404,
            detail=f"Task with ID {task_id} not found",
        )
    del tasks_db[task_id]

The key design decisions in this code merit explanation:

Status code 201 for creation: The POST /tasks endpoint returns 201 (Created) instead of the default 200, which is the correct HTTP semantic for resource creation.

Status code 204 for deletion: The DELETE endpoint returns 204 (No Content) with no response body, which is the standard for successful deletions.

HTTPException for errors: When a task is not found, an HTTPException is raised with a 404 status code and a human-readable detail message. FastAPI converts this into a proper JSON error response automatically.

Partial updates with exclude_unset: The model_dump(exclude_unset=True) call on the update model ensures that only fields explicitly sent by the client are updated. This is the correct behaviour for a PUT/PATCH endpoint.

Testing the CRUD API

The server is started with uvicorn app.main:app --reload, and the following requests may then be issued using curl:

# Create a task
curl -X POST http://localhost:8000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn FastAPI", "description": "Complete the tutorial", "priority": 5}'

# List all tasks
curl http://localhost:8000/tasks

# Get a specific task
curl http://localhost:8000/tasks/1

# Update a task
curl -X PUT http://localhost:8000/tasks/1 \
  -H "Content-Type: application/json" \
  -d '{"status": "in_progress"}'

# Filter tasks by status
curl "http://localhost:8000/tasks?status=in_progress"

# Delete a task
curl -X DELETE http://localhost:8000/tasks/1
Tip: All of these endpoints can also be tested interactively through the Swagger UI at http://localhost:8000/docs. It is much faster for exploration than writing curl commands.

Request Validation and Pydantic Models

One of FastAPI’s most powerful features is its deep integration with Pydantic for data validation. The capabilities of Pydantic beyond the basics already discussed are examined below.

Field Validation

Pydantic’s Field function provides fine-grained control over validation:

from pydantic import BaseModel, Field, field_validator
import re


class UserCreate(BaseModel):
    username: str = Field(
        ...,
        min_length=3,
        max_length=50,
        pattern=r"^[a-zA-Z0-9_]+$",
        description="Username (letters, numbers, underscores only)",
    )
    email: str = Field(
        ...,
        min_length=5,
        max_length=255,
        description="Valid email address",
    )
    age: int = Field(
        ...,
        gt=0,
        lt=150,
        description="Age in years",
    )
    score: float = Field(
        default=0.0,
        ge=0.0,
        le=100.0,
        description="Score between 0 and 100",
    )

    @field_validator("email")
    @classmethod
    def validate_email(cls, v: str) -> str:
        if "@" not in v or "." not in v.split("@")[-1]:
            raise ValueError("Invalid email address")
        return v.lower()

The validation constraints available include:

  • min_length / max_length — for strings
  • pattern — regex validation for strings
  • gt / ge / lt / le — greater than, greater or equal, less than, less or equal, for numbers
  • multiple_of — ensures a number is a multiple of a given value

Nested Models

Pydantic models can be nested to represent complex data structures:

from pydantic import BaseModel
from typing import Optional


class Address(BaseModel):
    street: str
    city: str
    state: str
    zip_code: str
    country: str = "US"


class ContactInfo(BaseModel):
    email: str
    phone: Optional[str] = None
    address: Optional[Address] = None


class Employee(BaseModel):
    name: str
    department: str
    contact: ContactInfo
    tags: list[str] = []


# This would be valid JSON input:
# {
#     "name": "Alice",
#     "department": "Engineering",
#     "contact": {
#         "email": "alice@example.com",
#         "address": {
#             "street": "123 Main St",
#             "city": "San Francisco",
#             "state": "CA",
#             "zip_code": "94102"
#         }
#     },
#     "tags": ["python", "fastapi"]
# }

Custom Validators

For complex validation logic that goes beyond simple field constraints, Pydantic offers model validators that can validate relationships between fields:

from pydantic import BaseModel, model_validator
from datetime import date


class DateRange(BaseModel):
    start_date: date
    end_date: date

    @model_validator(mode="after")
    def validate_date_range(self):
        if self.end_date < self.start_date:
            raise ValueError("end_date must be after start_date")
        return self


class PasswordChange(BaseModel):
    current_password: str
    new_password: str = Field(min_length=8)
    confirm_password: str

    @model_validator(mode="after")
    def passwords_match(self):
        if self.new_password != self.confirm_password:
            raise ValueError("new_password and confirm_password must match")
        if self.new_password == self.current_password:
            raise ValueError("New password must differ from current password")
        return self

When validation fails, FastAPI automatically returns a 422 (Unprocessable Entity) response with detailed error messages explaining exactly what went wrong and where. Clients receive clear, actionable error messages without any error-handling code having to be written.

Path Parameters, Query Parameters, and Request Body

FastAPI provides elegant means of extracting data from every part of an HTTP request. Each mechanism is examined below.

Path Parameters

Path parameters are extracted directly from the URL path and are always required:

from fastapi import Path

@app.get("/tasks/{task_id}/comments/{comment_id}")
def get_comment(
    task_id: int = Path(..., gt=0, description="The task ID"),
    comment_id: int = Path(..., gt=0, description="The comment ID"),
):
    return {"task_id": task_id, "comment_id": comment_id}

Query Parameters with Pagination

Query parameters are well suited to filtering, sorting, and pagination:

from fastapi import Query
from typing import Optional
from enum import Enum


class SortField(str, Enum):
    created_at = "created_at"
    priority = "priority"
    title = "title"


class SortOrder(str, Enum):
    asc = "asc"
    desc = "desc"


@app.get("/tasks")
def list_tasks(
    # Filtering
    status: Optional[TaskStatus] = Query(None),
    priority: Optional[int] = Query(None, ge=1, le=5),
    search: Optional[str] = Query(
        None, min_length=1, max_length=100,
        description="Search in title and description",
    ),
    # Sorting
    sort_by: SortField = Query(
        SortField.created_at, description="Field to sort by"
    ),
    order: SortOrder = Query(
        SortOrder.desc, description="Sort order"
    ),
    # Pagination
    skip: int = Query(0, ge=0, description="Records to skip"),
    limit: int = Query(20, ge=1, le=100, description="Max records"),
):
    """List tasks with filtering, sorting, and pagination."""
    results = list(tasks_db.values())

    if status:
        results = [t for t in results if t["status"] == status]
    if priority:
        results = [t for t in results if t["priority"] == priority]
    if search:
        results = [
            t for t in results
            if search.lower() in t["title"].lower()
            or (t["description"] and search.lower() in t["description"].lower())
        ]

    reverse = order == SortOrder.desc
    results.sort(key=lambda t: t[sort_by.value], reverse=reverse)

    return {
        "total": len(results),
        "skip": skip,
        "limit": limit,
        "tasks": results[skip : skip + limit],
    }

Combining Path, Query, and Body in One Endpoint

from fastapi import Path, Query, Body

@app.put("/projects/{project_id}/tasks/{task_id}")
def update_project_task(
    project_id: int = Path(..., gt=0),       # From URL path
    task_id: int = Path(..., gt=0),          # From URL path
    notify: bool = Query(False),              # From query string
    task_update: TaskUpdate = Body(...),      # From request body
):
    """
    URL: PUT /projects/5/tasks/42?notify=true
    Body: {"title": "Updated title", "priority": 3}
    """
    # project_id = 5 (from path)
    # task_id = 42 (from path)
    # notify = True (from query)
    # task_update = TaskUpdate(title="Updated title", priority=3) (from body)
    return {
        "project_id": project_id,
        "task_id": task_id,
        "notify": notify,
        "updates": task_update.model_dump(exclude_unset=True),
    }

FastAPI automatically determines where each parameter originates based on its type: simple types are path or query parameters, while Pydantic models constitute the request body. The Path, Query, and Body functions allow validation and documentation to be attached to each.

Adding a Database with SQLAlchemy

In-memory storage is acceptable for prototyping, but any real application requires persistent data storage. The following section integrates SQLite with SQLAlchemy; the same pattern works with PostgreSQL, MySQL, or any other database.

Install Database Dependencies

pip install sqlalchemy

Database Configuration

Create app/database.py:

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase

SQLALCHEMY_DATABASE_URL = "sqlite:///./tasks.db"
# For PostgreSQL:
# SQLALCHEMY_DATABASE_URL = "postgresql://user:password@localhost/dbname"

engine = create_engine(
    SQLALCHEMY_DATABASE_URL,
    connect_args={"check_same_thread": False},  # SQLite only
)

SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)


class Base(DeclarativeBase):
    pass


def get_db():
    """Dependency that provides a database session per request."""
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

Define Database Models

Create app/db_models.py:

from sqlalchemy import Column, Integer, String, DateTime, Enum as SQLEnum
from sqlalchemy.sql import func

from app.database import Base
from app.models import TaskStatus


class TaskDB(Base):
    __tablename__ = "tasks"

    id = Column(Integer, primary_key=True, index=True, autoincrement=True)
    title = Column(String(200), nullable=False)
    description = Column(String(2000), nullable=True)
    status = Column(
        SQLEnum(TaskStatus), default=TaskStatus.pending, nullable=False
    )
    priority = Column(Integer, default=1, nullable=False)
    created_at = Column(
        DateTime(timezone=True), server_default=func.now()
    )
    updated_at = Column(
        DateTime(timezone=True),
        server_default=func.now(),
        onupdate=func.now(),
    )

CRUD Operations Module

Create app/crud.py to separate database logic from endpoint logic:

from sqlalchemy.orm import Session
from typing import Optional

from app.db_models import TaskDB
from app.models import TaskCreate, TaskUpdate, TaskStatus


def get_tasks(
    db: Session,
    status: Optional[TaskStatus] = None,
    priority: Optional[int] = None,
    skip: int = 0,
    limit: int = 20,
) -> list[TaskDB]:
    query = db.query(TaskDB)

    if status is not None:
        query = query.filter(TaskDB.status == status)
    if priority is not None:
        query = query.filter(TaskDB.priority == priority)

    return query.offset(skip).limit(limit).all()


def get_task(db: Session, task_id: int) -> Optional[TaskDB]:
    return db.query(TaskDB).filter(TaskDB.id == task_id).first()


def create_task(db: Session, task: TaskCreate) -> TaskDB:
    db_task = TaskDB(**task.model_dump())
    db.add(db_task)
    db.commit()
    db.refresh(db_task)
    return db_task


def update_task(
    db: Session, task_id: int, task_update: TaskUpdate
) -> Optional[TaskDB]:
    db_task = db.query(TaskDB).filter(TaskDB.id == task_id).first()
    if db_task is None:
        return None

    update_data = task_update.model_dump(exclude_unset=True)
    for field, value in update_data.items():
        setattr(db_task, field, value)

    db.commit()
    db.refresh(db_task)
    return db_task


def delete_task(db: Session, task_id: int) -> bool:
    db_task = db.query(TaskDB).filter(TaskDB.id == task_id).first()
    if db_task is None:
        return False
    db.delete(db_task)
    db.commit()
    return True

Refactored Endpoints with Database

The endpoints in app/main.py are now updated to use the database:

from fastapi import FastAPI, HTTPException, Query, Depends
from sqlalchemy.orm import Session
from typing import Optional

from app.models import (
    TaskCreate, TaskUpdate, TaskResponse, TaskStatus,
)
from app.database import engine, get_db
from app.db_models import Base
from app import crud

# Create database tables on startup
Base.metadata.create_all(bind=engine)

app = FastAPI(
    title="Task Manager API",
    description="A complete REST API for managing tasks",
    version="1.0.0",
)


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


@app.get("/tasks", response_model=list[TaskResponse])
def list_tasks(
    status: Optional[TaskStatus] = Query(None),
    priority: Optional[int] = Query(None, ge=1, le=5),
    skip: int = Query(0, ge=0),
    limit: int = Query(20, ge=1, le=100),
    db: Session = Depends(get_db),
):
    """Retrieve all tasks with optional filtering and pagination."""
    return crud.get_tasks(db, status=status, priority=priority,
                          skip=skip, limit=limit)


@app.get("/tasks/{task_id}", response_model=TaskResponse)
def get_task(task_id: int, db: Session = Depends(get_db)):
    """Retrieve a single task by its ID."""
    task = crud.get_task(db, task_id)
    if task is None:
        raise HTTPException(status_code=404,
                            detail=f"Task {task_id} not found")
    return task


@app.post("/tasks", response_model=TaskResponse, status_code=201)
def create_task(task: TaskCreate, db: Session = Depends(get_db)):
    """Create a new task."""
    return crud.create_task(db, task)


@app.put("/tasks/{task_id}", response_model=TaskResponse)
def update_task(
    task_id: int,
    task_update: TaskUpdate,
    db: Session = Depends(get_db),
):
    """Update an existing task."""
    task = crud.update_task(db, task_id, task_update)
    if task is None:
        raise HTTPException(status_code=404,
                            detail=f"Task {task_id} not found")
    return task


@app.delete("/tasks/{task_id}", status_code=204)
def delete_task(task_id: int, db: Session = Depends(get_db)):
    """Delete a task by its ID."""
    if not crud.delete_task(db, task_id):
        raise HTTPException(status_code=404,
                            detail=f"Task {task_id} not found")

The key change is the Depends(get_db) pattern. This is FastAPI’s dependency injection system: it automatically creates a database session for each request and closes it when the request is complete, even if an error occurs. The pattern is clean, testable, and avoids global state.

Tip: For new projects, SQLModel may be preferable to separate SQLAlchemy and Pydantic models. Created by the same author as FastAPI, SQLModel permits a single class to serve as both Pydantic model and SQLAlchemy model, significantly reducing duplication.

Authentication and Security

No production API is complete without authentication. Two approaches are implemented below: a simple API key for server-to-server communication, and JWT tokens for user-facing authentication.

Simple API Key Authentication

Create app/auth.py:

from fastapi import Depends, HTTPException, Security, status
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import JWTError, jwt
from passlib.context import CryptContext
from datetime import datetime, timedelta
from typing import Optional
from pydantic import BaseModel

# ── API Key Authentication ──────────────────────────

API_KEY = "your-secret-api-key-here"  # In production, load from env
api_key_header = APIKeyHeader(name="X-API-Key")


def verify_api_key(api_key: str = Security(api_key_header)):
    if api_key != API_KEY:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Invalid API key",
        )
    return api_key


# ── JWT Authentication ──────────────────────────────

SECRET_KEY = "your-jwt-secret-key"  # In production, load from env
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")


class Token(BaseModel):
    access_token: str
    token_type: str


class TokenData(BaseModel):
    username: Optional[str] = None


class User(BaseModel):
    username: str
    email: str
    disabled: bool = False


class UserInDB(User):
    hashed_password: str


# Simulated user database
fake_users_db = {
    "admin": {
        "username": "admin",
        "email": "admin@example.com",
        "hashed_password": pwd_context.hash("secretpassword"),
        "disabled": False,
    }
}


def verify_password(plain_password: str, hashed_password: str) -> bool:
    return pwd_context.verify(plain_password, hashed_password)


def create_access_token(
    data: dict, expires_delta: Optional[timedelta] = None
) -> str:
    to_encode = data.copy()
    expire = datetime.utcnow() + (
        expires_delta or timedelta(minutes=15)
    )
    to_encode.update({"exp": expire})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)


def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        username: str = payload.get("sub")
        if username is None:
            raise credentials_exception
    except JWTError:
        raise credentials_exception

    user_data = fake_users_db.get(username)
    if user_data is None:
        raise credentials_exception

    return User(**user_data)

Protecting Endpoints

Any endpoint can now be protected by adding the dependency:

from app.auth import (
    verify_api_key, get_current_user, User, Token,
    create_access_token, verify_password, fake_users_db,
    ACCESS_TOKEN_EXPIRE_MINUTES,
)
from fastapi.security import OAuth2PasswordRequestForm


# Token endpoint for JWT login
@app.post("/token", response_model=Token)
def login(form_data: OAuth2PasswordRequestForm = Depends()):
    user_data = fake_users_db.get(form_data.username)
    if not user_data or not verify_password(
        form_data.password, user_data["hashed_password"]
    ):
        raise HTTPException(
            status_code=401,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )

    access_token = create_access_token(
        data={"sub": form_data.username},
        expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
    )
    return {"access_token": access_token, "token_type": "bearer"}


# Protected endpoint — requires JWT token
@app.get("/users/me", response_model=User)
def read_users_me(current_user: User = Depends(get_current_user)):
    return current_user


# Protected endpoint — requires API key
@app.delete("/admin/clear-tasks", dependencies=[Depends(verify_api_key)])
def clear_all_tasks(db: Session = Depends(get_db)):
    db.query(TaskDB).delete()
    db.commit()
    return {"message": "All tasks deleted"}

Install the required packages for JWT authentication:

pip install python-jose[cryptography] passlib[bcrypt]
Caution: Secret keys and passwords must never be hard-coded in source code. In a production application, SECRET_KEY, API_KEY, and database credentials should always be loaded from environment variables using python-dotenv or pydantic-settings. The hard-coded values here are for tutorial purposes only. For a broader treatment of containerising the API securely, see the related Docker containers explained guide.

Middleware, CORS, and Error Handling

As the API grows, cross-cutting concerns such as CORS support (so that frontends can call the API), request logging, and global error handling become necessary.

Adding CORS for Frontend Access

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "http://localhost:3000",      # React dev server
        "https://yourdomain.com",      # Production frontend
    ],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

Custom Middleware for Logging and Timing

import time
import logging
from fastapi import Request

logger = logging.getLogger("api")


@app.middleware("http")
async def log_requests(request: Request, call_next):
    start_time = time.time()

    # Process the request
    response = await call_next(request)

    # Calculate duration
    duration = time.time() - start_time

    logger.info(
        f"{request.method} {request.url.path} "
        f"- Status: {response.status_code} "
        f"- Duration: {duration:.3f}s"
    )

    # Add timing header to response
    response.headers["X-Process-Time"] = f"{duration:.3f}"
    return response

Global Exception Handlers

from fastapi import Request
from fastapi.responses import JSONResponse


@app.exception_handler(ValueError)
async def value_error_handler(request: Request, exc: ValueError):
    return JSONResponse(
        status_code=400,
        content={
            "error": "Bad Request",
            "detail": str(exc),
        },
    )


@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
    logger.error(f"Unhandled exception: {exc}", exc_info=True)
    return JSONResponse(
        status_code=500,
        content={
            "error": "Internal Server Error",
            "detail": "An unexpected error occurred",
        },
    )

The general exception handler is particularly important for production: it prevents stack traces from leaking to clients while still logging the full error for debugging.

Testing the API

FastAPI makes testing exceptionally straightforward with its built-in TestClient, which is a wrapper around httpx. The entire API can be tested without starting a server.

Setting Up Tests

Install pytest if it is not already present:

pip install pytest httpx

Create tests/test_tasks.py:

import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

from app.main import app
from app.database import Base, get_db

# Use an in-memory SQLite database for tests
TEST_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(
    TEST_DATABASE_URL,
    connect_args={"check_same_thread": False},
)
TestingSessionLocal = sessionmaker(
    autocommit=False, autoflush=False, bind=engine
)


def override_get_db():
    db = TestingSessionLocal()
    try:
        yield db
    finally:
        db.close()


# Override the database dependency
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)


@pytest.fixture(autouse=True)
def setup_database():
    """Create tables before each test, drop after."""
    Base.metadata.create_all(bind=engine)
    yield
    Base.metadata.drop_all(bind=engine)


def test_read_root():
    response = client.get("/")
    assert response.status_code == 200
    assert response.json() == {"message": "Welcome to the Task Manager API"}


def test_create_task():
    response = client.post(
        "/tasks",
        json={
            "title": "Test Task",
            "description": "A test task",
            "priority": 3,
        },
    )
    assert response.status_code == 201
    data = response.json()
    assert data["title"] == "Test Task"
    assert data["description"] == "A test task"
    assert data["priority"] == 3
    assert data["status"] == "pending"
    assert "id" in data
    assert "created_at" in data


def test_create_task_validation_error():
    response = client.post(
        "/tasks",
        json={"title": "", "priority": 10},  # Empty title, priority too high
    )
    assert response.status_code == 422


def test_get_task():
    # Create a task first
    create_response = client.post(
        "/tasks", json={"title": "Find me"}
    )
    task_id = create_response.json()["id"]

    # Retrieve it
    response = client.get(f"/tasks/{task_id}")
    assert response.status_code == 200
    assert response.json()["title"] == "Find me"


def test_get_task_not_found():
    response = client.get("/tasks/99999")
    assert response.status_code == 404


def test_update_task():
    # Create a task
    create_response = client.post(
        "/tasks", json={"title": "Original Title"}
    )
    task_id = create_response.json()["id"]

    # Update it
    response = client.put(
        f"/tasks/{task_id}",
        json={"title": "Updated Title", "status": "in_progress"},
    )
    assert response.status_code == 200
    assert response.json()["title"] == "Updated Title"
    assert response.json()["status"] == "in_progress"


def test_delete_task():
    # Create a task
    create_response = client.post(
        "/tasks", json={"title": "Delete me"}
    )
    task_id = create_response.json()["id"]

    # Delete it
    response = client.delete(f"/tasks/{task_id}")
    assert response.status_code == 204

    # Verify it is gone
    response = client.get(f"/tasks/{task_id}")
    assert response.status_code == 404


def test_list_tasks_with_filter():
    # Create tasks with different statuses
    client.post(
        "/tasks", json={"title": "Task 1", "status": "pending"}
    )
    client.post(
        "/tasks", json={"title": "Task 2", "status": "completed"}
    )
    client.post(
        "/tasks", json={"title": "Task 3", "status": "pending"}
    )

    # Filter by status
    response = client.get("/tasks?status=pending")
    assert response.status_code == 200
    tasks = response.json()
    assert len(tasks) == 2
    assert all(t["status"] == "pending" for t in tasks)


def test_list_tasks_pagination():
    # Create 5 tasks
    for i in range(5):
        client.post("/tasks", json={"title": f"Task {i}"})

    # Get first page
    response = client.get("/tasks?skip=0&limit=2")
    assert response.status_code == 200
    assert len(response.json()) == 2

    # Get second page
    response = client.get("/tasks?skip=2&limit=2")
    assert response.status_code == 200
    assert len(response.json()) == 2

Run the tests:

pytest tests/ -v
Key Takeaway: The dependency-injection system renders testing clean: the real database is replaced by a test database with a single line (app.dependency_overrides[get_db] = override_get_db). No mocking, no patching, no test doubles. This is one of FastAPI’s most underappreciated features.

Deployment

The following section describes taking the API from development to production.

Running in Production with Gunicorn

In production, Uvicorn should be run behind Gunicorn for process management and multi-worker support:

pip install gunicorn

# Run with 4 worker processes
gunicorn app.main:app \
    --workers 4 \
    --worker-class uvicorn.workers.UvicornWorker \
    --bind 0.0.0.0:8000 \
    --access-logfile - \
    --error-logfile -

A useful rule of thumb for the number of workers is (2 x CPU cores) + 1. For a 2-core server, five workers are appropriate.

Docker Containerisation

A Dockerfile is used to containerise the FastAPI application. For a thorough treatment of Docker from development to production, including multi-stage builds and Docker Compose, see the related Docker containers guide for development and production:

# Use the official Python slim image
FROM python:3.11-slim

# Set working directory
WORKDIR /app

# Install dependencies first (leverages Docker caching)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy application code
COPY app/ ./app/

# Create non-root user for security
RUN adduser --disabled-password --gecos "" appuser
USER appuser

# Expose port
EXPOSE 8000

# Run with Gunicorn in production
CMD ["gunicorn", "app.main:app", \
     "--workers", "4", \
     "--worker-class", "uvicorn.workers.UvicornWorker", \
     "--bind", "0.0.0.0:8000"]

And a docker-compose.yml for easy local testing:

version: "3.8"
services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgresql://postgres:password@db:5432/taskmanager
      - SECRET_KEY=your-production-secret-key
    depends_on:
      - db

  db:
    image: postgres:16
    environment:
      - POSTGRES_DB=taskmanager
      - POSTGRES_PASSWORD=password
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

volumes:
  postgres_data:

Build and run:

docker-compose up --build

Cloud Deployment Options

Several cloud-deployment options are available, depending on scale and budget:

  • AWS Lightsail or EC2 — full control, appropriate for small to medium deployments
  • Google Cloud Run — serverless containers, scaling to zero, pay-per-request pricing
  • Railway or Render — simple PaaS options with generous free tiers
  • AWS Lambda with Mangum — serverless deployment using the Mangum ASGI adapter

Best Practices

As an API grows beyond a simple tutorial, the following practices keep the codebase maintainable and the API reliable.

Project Structure for Larger Applications

For larger applications, the code should be organised using FastAPI’s router system:

app/
├── __init__.py
├── main.py                 # App factory, middleware, startup events
├── config.py               # Settings via pydantic-settings
├── database.py             # DB engine, session, base
├── dependencies.py         # Shared dependencies (auth, db session)
├── models/                 # SQLAlchemy models
│   ├── __init__.py
│   ├── task.py
│   └── user.py
├── schemas/                # Pydantic schemas
│   ├── __init__.py
│   ├── task.py
│   └── user.py
├── routers/                # API route handlers
│   ├── __init__.py
│   ├── tasks.py
│   └── users.py
├── services/               # Business logic
│   ├── __init__.py
│   ├── task_service.py
│   └── user_service.py
└── middleware/              # Custom middleware
    ├── __init__.py
    └── logging.py

Each router file has the following structure:

# app/routers/tasks.py
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session

from app.dependencies import get_db, get_current_user
from app.schemas.task import TaskCreate, TaskResponse
from app.services import task_service

router = APIRouter(
    prefix="/tasks",
    tags=["tasks"],
    dependencies=[Depends(get_current_user)],
)


@router.get("/", response_model=list[TaskResponse])
def list_tasks(db: Session = Depends(get_db)):
    return task_service.get_all_tasks(db)

The main file then includes the routers:

# app/main.py
from fastapi import FastAPI
from app.routers import tasks, users

app = FastAPI(title="Task Manager API")
app.include_router(tasks.router)
app.include_router(users.router)

Environment Variables with Pydantic Settings

# app/config.py
from pydantic_settings import BaseSettings
from functools import lru_cache


class Settings(BaseSettings):
    database_url: str = "sqlite:///./tasks.db"
    secret_key: str = "change-me-in-production"
    api_key: str = "change-me-in-production"
    debug: bool = False
    allowed_origins: list[str] = ["http://localhost:3000"]

    class Config:
        env_file = ".env"


@lru_cache
def get_settings() -> Settings:
    return Settings()


# Usage in endpoints:
# settings = Depends(get_settings)

API Versioning

# Version via URL prefix
v1_router = APIRouter(prefix="/api/v1")
v2_router = APIRouter(prefix="/api/v2")

app.include_router(v1_router)
app.include_router(v2_router)

Rate Limiting

For rate limiting, the slowapi library integrates cleanly with FastAPI:

pip install slowapi
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded

limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)


@app.get("/tasks")
@limiter.limit("60/minute")
def list_tasks(request: Request):
    ...
Key Takeaway: FastAPI’s modular architecture — routers, dependency injection, Pydantic settings — makes it straightforward to scale from a single-file prototype to a well-structured production application. The appropriate approach is to begin simply and refactor as the project grows.

Concluding Observations

This guide has covered substantial ground. Beginning from a simple “Hello World” endpoint, a complete task-management API has been constructed with CRUD operations, database persistence using SQLAlchemy, authentication with both API keys and JWT tokens, CORS support, custom middleware, comprehensive tests, and a production deployment configured with Docker.

What distinguishes FastAPI is not any single feature; it is how all of its features work together seamlessly. Type hints drive validation, documentation, and editor support simultaneously. Dependency injection keeps code testable and modular. Pydantic models serve as the single source of truth for data contracts. The async foundation permits the API to handle serious traffic without complex optimisation.

The components constructed in this guide are summarised below:

Component Technology Purpose
Framework FastAPI API routing, validation, docs
Server Uvicorn / Gunicorn ASGI server for production
Validation Pydantic Request/response data models
Database SQLAlchemy + SQLite Persistent data storage
Authentication JWT + API Keys Secure endpoint access
Testing pytest + TestClient Automated API testing
Deployment Docker + Gunicorn Containerized production setup

 

For teams seeking still more performance from the API layer, writing performance-critical endpoints as native extensions is becoming practical owing to Python and Rust interoperability via PyO3. For developers migrating from Flask, the transition to FastAPI is remarkably smooth: most concepts map directly, and type safety, auto-generated documentation, and improved performance are gained without additional effort. For developers migrating from Django REST Framework, the lighter weight and more explicit architecture, with comparable functionality, are likely to be appreciated.

The Python web ecosystem has evolved significantly, and FastAPI represents the present state of the art. Whether the project is a simple microservice, a complex multi-tenant SaaS, or a high-performance data API, FastAPI provides the tools to build it cleanly and efficiently.

As the codebase grows, following clean-code principles and using Git best practices for professional developers will keep the API maintainable. Building something real is the appropriate next step. The task manager constructed here can be extended with additional features — tags, due dates, user assignments, notifications — and deployed. The most effective way to learn a framework is to ship something with it.

References

You Might Also Like

Comments

Leave a Reply

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