FastAPI – Introduction & Setup

Introduction

FastAPI is a modern, high-performance web framework for building APIs with Python 3.7+ based on standard Python type hints. Created by Sebastián Ramírez (tiangolo) and first released in December 2018, FastAPI has rapidly become one of the most popular Python web frameworks, earning over 75,000 GitHub stars and adoption by companies like Microsoft, Netflix, Uber, and Samsung.

FastAPI is built on two foundational libraries:

  • Starlette — A lightweight ASGI (Asynchronous Server Gateway Interface) framework that provides the core web functionality: routing, request/response handling, WebSocket support, middleware, and background tasks. Starlette gives FastAPI its exceptional performance characteristics.
  • Pydantic — A data validation and serialization library that uses Python type annotations to define data schemas. Pydantic powers FastAPI’s automatic request validation, response serialization, and documentation generation. All without writing a single line of validation code manually.

What makes FastAPI stand out from other Python web frameworks is its philosophy: leverage modern Python features — type hints, async/await, and dataclasses — to eliminate boilerplate, catch errors at development time, and automatically generate production-ready API documentation. You write less code, and the code you write is more correct.

The name “FastAPI” refers to two things: the speed of the framework itself (comparable to Node.js and Go in benchmarks) and the speed at which you can develop APIs with it. Sebastián Ramírez estimates that FastAPI increases developer productivity by 200-300% compared to traditional approaches, while simultaneously reducing bugs by about 40% through its type-driven validation system.

This tutorial series will take you from zero to building production-ready APIs with FastAPI. We will cover everything a senior developer needs to know — from basic routing to database integration, authentication, testing, and deployment. By the end, you will have the skills to architect and build real-world applications with FastAPI.


Why FastAPI?

Before diving into code, you need to understand why FastAPI has taken the Python ecosystem by storm. There are five core reasons that make it the framework of choice for modern Python API development.

1. Blazing Fast Performance

FastAPI consistently ranks among the fastest Python web frameworks in independent benchmarks. Built on Starlette and running on Uvicorn (an ASGI server powered by uvloop and httptools), FastAPI achieves throughput numbers that rival Node.js and Go frameworks.

Framework Language Requests/sec (JSON serialization) Relative Speed
Uvicorn (raw ASGI) Python ~70,000 1.0x (baseline)
FastAPI Python ~50,000-60,000 0.8x
Express.js Node.js ~45,000-55,000 0.75x
Flask (Gunicorn) Python ~8,000-12,000 0.15x
Django (Gunicorn) Python ~5,000-8,000 0.10x
Gin Go ~80,000-100,000 1.3x
Note: Benchmarks vary by hardware, payload size, and test methodology. The numbers above represent typical results from the TechEmpower Framework Benchmarks and similar independent tests. The key takeaway is that FastAPI is in the same performance league as Node.js and not far behind compiled languages like Go.

The performance advantage over Flask and Django comes from two architectural decisions:

  • ASGI vs WSGI — FastAPI uses the Asynchronous Server Gateway Interface, which supports non-blocking I/O natively. When your API waits for a database query, file read, or external API call, the server can handle other requests simultaneously. WSGI frameworks like Flask block the entire worker process during I/O operations.
  • Uvicorn’s event loop — Uvicorn uses uvloop, a drop-in replacement for Python’s asyncio event loop written in Cython. It is 2-4x faster than the default event loop and comparable to the event loop implementations in Node.js and Go.

2. Automatic Interactive Documentation

FastAPI automatically generates interactive API documentation from your code. You get two documentation interfaces out of the box, with zero additional configuration:

  • Swagger UI (available at /docs) — An interactive documentation page where you can read about every endpoint, see request/response schemas, and test endpoints directly from the browser. Click “Try it out,” fill in parameters, hit “Execute,” and see the response immediately.
  • ReDoc (available at /redoc) — A cleaner, more readable documentation view that is ideal for sharing with frontend developers, QA teams, or external API consumers. ReDoc generates a three-panel layout with navigation, endpoint details, and request/response examples.

Both documentation interfaces are generated from the OpenAPI (formerly Swagger) schema that FastAPI creates automatically from your route definitions, type hints, and Pydantic models. Every parameter type, validation rule, response model, and example you define in code appears in the docs instantly. There is no separate documentation file to maintain, no YAML spec to write by hand, and no risk of your docs drifting out of sync with your code.

3. Type Safety and Automatic Validation

FastAPI uses Python type hints not just for documentation, but for runtime data validation. When you declare that a parameter is an int, FastAPI will automatically reject requests that send a string. When you define a Pydantic model with constraints, FastAPI validates every incoming request against that model before your handler function ever runs.

from fastapi import FastAPI
from pydantic import BaseModel, Field

app = FastAPI()

class UserCreate(BaseModel):
    username: str = Field(..., min_length=3, max_length=50)
    email: str = Field(..., pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$")
    age: int = Field(..., ge=13, le=120)

@app.post("/users")
async def create_user(user: UserCreate):
    # If we reach this line, the data is guaranteed valid
    # FastAPI already validated username length, email format, and age range
    return {"message": f"User {user.username} created successfully"}

If a client sends {"username": "ab", "email": "not-an-email", "age": 5}, FastAPI returns a detailed 422 Unprocessable Entity response listing every validation error — without you writing a single line of validation code.

4. Native Async Support

FastAPI was built from the ground up for Python’s async/await syntax. Unlike Flask, which bolted on async support years after its initial release, FastAPI treats asynchronous programming as a first-class citizen.

import httpx
from fastapi import FastAPI

app = FastAPI()

@app.get("/weather/{city}")
async def get_weather(city: str):
    # Non-blocking HTTP call - server handles other requests while waiting
    async with httpx.AsyncClient() as client:
        response = await client.get(
            f"https://api.weatherapi.com/v1/current.json?q={city}"
        )
    return response.json()

With async, a single server process can handle thousands of concurrent connections. This is critical for modern API backends that aggregate data from multiple services, interact with databases, or serve real-time data over WebSockets.

Tip: FastAPI also works perfectly well with regular synchronous functions. If your handler does not need async, just define it with def instead of async def. FastAPI will run it in a thread pool to avoid blocking the event loop. You can mix sync and async handlers freely in the same application.

5. Developer Productivity

FastAPI dramatically reduces the amount of code you need to write. Features that require dozens of lines in other frameworks — request validation, serialization, documentation, error handling — happen automatically. Consider what you get for free just by adding type hints:

  • Request body parsing and validation
  • Query parameter parsing and validation
  • Path parameter parsing and type conversion
  • Response serialization and filtering
  • OpenAPI schema generation
  • Interactive documentation
  • Editor autocompletion and type checking

Your IDE also becomes dramatically more helpful. Because FastAPI uses standard Python type hints, editors like VS Code and PyCharm provide accurate autocompletion, inline error detection, and refactoring support. You catch bugs before running your code, not after deploying it.


FastAPI vs Flask vs Django

Choosing the right Python web framework is one of the most important architectural decisions you will make. Each framework has distinct strengths, and the right choice depends on your project requirements, team experience, and deployment constraints. Here is a detailed comparison.

Feature FastAPI Flask Django
Released 2018 2010 2005
Type ASGI (async-first) WSGI (sync, async bolt-on) WSGI (async views since 3.1)
Performance Very high (~50K req/s) Moderate (~10K req/s) Moderate (~7K req/s)
Async Support Native, first-class Limited (since 2.0) Partial (views only, ORM sync)
Data Validation Built-in (Pydantic) Manual or third-party (marshmallow) Built-in (forms/serializers)
API Documentation Auto-generated (Swagger + ReDoc) Manual or third-party (flasgger) Manual or third-party (DRF)
ORM None built-in (use SQLAlchemy, Tortoise) None built-in (use SQLAlchemy) Built-in Django ORM
Admin Panel None built-in None built-in (Flask-Admin) Built-in, powerful
Authentication OAuth2, JWT via dependencies Third-party (Flask-Login, JWT) Built-in (sessions, permissions)
Template Engine Jinja2 (optional) Jinja2 (built-in) Django Templates (built-in)
Type Hints Required, core feature Optional, decorative Optional, decorative
WebSocket Support Built-in, native Third-party (Flask-SocketIO) Django Channels (add-on)
Learning Curve Low-Medium Low Medium-High
Ecosystem Size Growing rapidly Very large, mature Largest, most mature
Best For APIs, microservices, async backends Simple APIs, prototypes, learning Full-stack web apps, CMS, e-commerce
Deployment Uvicorn / Gunicorn + Uvicorn workers Gunicorn / uWSGI Gunicorn / uWSGI

When to Choose FastAPI

  • You are building a REST or GraphQL API. FastAPI was purpose-built for APIs. Automatic validation, serialization, and documentation make it the most productive choice for API development.
  • Performance matters. If your application handles high concurrency or real-time data (WebSockets, server-sent events, streaming), FastAPI’s async-first architecture delivers throughput that Flask and Django cannot match without significant architectural changes.
  • You value type safety. If you prefer catching errors at development time rather than in production, FastAPI’s type-driven approach is ideal. Your IDE becomes a powerful validation tool.
  • You need modern async features. Background tasks, WebSocket connections, streaming responses, and concurrent external API calls are natural in FastAPI.
  • Your team already uses type hints. If your Python codebase already uses type annotations, FastAPI will feel like a natural extension of how you write code.

When to Choose Flask

  • You need maximum flexibility. Flask’s unopinionated nature lets you structure your project however you want. There is no prescribed way to do anything.
  • You are prototyping quickly. Flask’s simplicity means you can go from idea to working prototype in minutes. A “Hello World” is 5 lines of code.
  • You are building a small web application with templates. If your application serves HTML pages with minimal API surface, Flask’s Jinja2 integration is excellent.
  • You need a mature ecosystem. Flask has been around since 2010 and has extensions for virtually everything. Whatever you need, someone has probably built a Flask extension for it.

When to Choose Django

  • You are building a full-stack web application. Django’s “batteries included” approach (ORM, admin panel, authentication, forms, migrations) is unmatched for traditional web applications.
  • You need a built-in admin interface. Django’s admin panel is one of the most powerful features in any web framework. For content management, back-office tools, and data administration, nothing comes close.
  • You want convention over configuration. Django makes many decisions for you. In a large team, this consistency is valuable.
  • You are building an e-commerce site or CMS. Django powers sites like Instagram, Pinterest, and Mozilla. Its ecosystem for content-heavy applications is the most mature in Python.
Key Insight: FastAPI, Flask, and Django are not competitors — they solve different problems. Many production systems use FastAPI for high-performance API services alongside Django for admin interfaces and content management. Choose based on your specific requirements, not framework popularity.

Installation & Setup

Prerequisites

Before installing FastAPI, ensure you have the following:

  • Python 3.7 or higher — FastAPI requires Python 3.7+ because it relies on type hints and async/await syntax. We recommend Python 3.10+ for the best experience (better error messages, structural pattern matching, and improved type hint syntax).
  • pip — Python’s package installer (included with Python 3.4+).
  • A virtual environment — Always use a virtual environment for Python projects. If you are unfamiliar with virtual environments, review our Python Advanced – Virtual Environments & pip tutorial.

Check your Python version:

python3 --version
# Python 3.11.6 (or higher)

Creating a Virtual Environment

Create a dedicated directory for your FastAPI project and set up a virtual environment:

# Create project directory
mkdir fastapi-tutorial
cd fastapi-tutorial

# Create virtual environment
python3 -m venv venv

# Activate virtual environment
# On macOS/Linux:
source venv/bin/activate

# On Windows:
# venv\Scripts\activate

# Verify activation (should show venv path)
which python

Installing FastAPI and Uvicorn

FastAPI itself is just the framework. You also need an ASGI server to run your application. Uvicorn is the recommended choice.

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

The fastapi[standard] install includes:

  • fastapi — The framework itself
  • uvicorn[standard] — The ASGI server with high-performance extras (uvloop, httptools)
  • pydantic — Data validation (core dependency)
  • starlette — The ASGI toolkit (core dependency)
  • python-multipart — Required for form data parsing
  • email-validator — Email validation support for Pydantic
  • httpx — Async HTTP client (for testing)
  • jinja2 — Template engine (optional, for HTML responses)
Tip: If you want a minimal installation, you can install just the core: pip install fastapi uvicorn. The [standard] extras are recommended because they include everything you will need for a real project.

Alternatively, install components individually for more control:

# Minimal installation
pip install fastapi uvicorn

# Add extras as needed
pip install python-multipart   # For form data
pip install python-jose[cryptography]  # For JWT tokens
pip install passlib[bcrypt]    # For password hashing
pip install sqlalchemy         # For database ORM
pip install alembic            # For database migrations

Verify the installation:

pip show fastapi

Expected output:

Name: fastapi
Version: 0.115.x
Summary: FastAPI framework, high performance, easy to learn, fast to code, ready for production
Home-page: https://github.com/fastapi/fastapi
Author-email: Sebastián Ramírez <tiangolo@gmail.com>
License: MIT
Requires: pydantic, starlette, typing-extensions

Freezing Dependencies

Always save your installed packages to a requirements file so your project is reproducible:

# Save current dependencies
pip freeze > requirements.txt

# Later, install from requirements file
pip install -r requirements.txt

Your First FastAPI Application

Let us build a “Hello World” application to see FastAPI in action. Create a file called main.py:

from fastapi import FastAPI

# Create the FastAPI application instance
app = FastAPI()

# Define a route using a path operation decorator
@app.get("/")
async def root():
    """Root endpoint that returns a welcome message."""
    return {"message": "Hello, World!"}

That is it. Four lines of actual code (plus the import) and you have a fully functional API with automatic documentation. Let us break down what each line does:

  1. from fastapi import FastAPI — Import the FastAPI class from the framework.
  2. app = FastAPI() — Create an application instance. This is the central object that holds all your routes, middleware, event handlers, and configuration. By convention, this variable is named app.
  3. @app.get("/") — A path operation decorator. It tells FastAPI that the function below handles GET requests to the path /. The @app.get decorator registers the function as a handler for HTTP GET requests.
  4. async def root() — The path operation function (also called a route handler or endpoint function). It is declared as async because FastAPI is an async framework. This function runs whenever a GET request hits /.
  5. return {"message": "Hello, World!"} — Return a Python dictionary. FastAPI automatically converts this to a JSON response with the correct Content-Type: application/json header and a 200 status code.

Running the Application

Start the development server with Uvicorn:

uvicorn main:app --reload

Let us break down this command:

  • main — The Python module (file) name, main.py
  • app — The FastAPI application instance inside that module
  • --reload — Enable auto-reload. Uvicorn watches your files and automatically restarts the server when you save changes. Use this only in development, never in production.

You should see output like this:

INFO:     Will watch for changes in these directories: ['/path/to/fastapi-tutorial']
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [28720] using StatReload
INFO:     Started server process [28722]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

Testing Your Endpoint

Open your browser and navigate to http://127.0.0.1:8000. You should see:

{"message": "Hello, World!"}

Or test with curl from another terminal:

curl http://127.0.0.1:8000
# {"message":"Hello, World!"}

Adding More Endpoints

Let us expand our application with a few more endpoints to demonstrate different features:

from fastapi import FastAPI

app = FastAPI(
    title="My First FastAPI App",
    description="A tutorial application to learn FastAPI basics",
    version="1.0.0"
)

@app.get("/")
async def root():
    """Root endpoint - welcome message."""
    return {"message": "Hello, World!", "docs": "/docs"}

@app.get("/health")
async def health_check():
    """Health check endpoint for monitoring."""
    return {"status": "healthy"}

@app.get("/items/{item_id}")
async def read_item(item_id: int):
    """Read a single item by its ID.

    The item_id parameter is automatically parsed as an integer.
    If a non-integer is provided, FastAPI returns a 422 error.
    """
    return {"item_id": item_id, "name": f"Item {item_id}"}

@app.get("/search")
async def search_items(q: str, limit: int = 10, offset: int = 0):
    """Search for items with query parameters.

    - q: required search query (string)
    - limit: optional, defaults to 10
    - offset: optional, defaults to 0
    """
    return {
        "query": q,
        "limit": limit,
        "offset": offset,
        "results": [f"Result for '{q}' #{i}" for i in range(offset, offset + limit)]
    }

Save the file, and Uvicorn will automatically reload. Try these URLs:

  • http://127.0.0.1:8000/ — Welcome message
  • http://127.0.0.1:8000/health — Health check
  • http://127.0.0.1:8000/items/42 — Item lookup (try /items/abc to see validation error)
  • http://127.0.0.1:8000/search?q=python — Search with query parameter
  • http://127.0.0.1:8000/search?q=python&limit=5&offset=10 — Search with all parameters
Important: The --reload flag should only be used during development. In production, run Uvicorn without --reload and with multiple workers: uvicorn main:app --workers 4. We will cover production deployment in detail in the FastAPI – Deployment tutorial.

Automatic Interactive Documentation

One of FastAPI’s most impressive features is automatic API documentation. Without writing a single line of documentation code, you already have two fully interactive documentation interfaces.

Swagger UI (/docs)

Navigate to http://127.0.0.1:8000/docs to access the Swagger UI documentation. This interface provides:

  • A list of all endpoints grouped by tags, showing the HTTP method, path, and summary
  • Request parameter details — path parameters, query parameters, request bodies, with their types and constraints
  • Response schemas — the structure and types of response data
  • “Try it out” functionality — click any endpoint, fill in parameters, click “Execute,” and see the actual response from your running server
  • cURL commands — after executing a request, Swagger UI shows the equivalent curl command

Here is what happens when you click “Try it out” on the /items/{item_id} endpoint:

  1. Swagger UI shows an input field for item_id
  2. You enter a value (e.g., 42)
  3. Click “Execute”
  4. Swagger UI sends a real HTTP request to your server
  5. The response appears below, along with the response code, headers, and the curl command equivalent

ReDoc (/redoc)

Navigate to http://127.0.0.1:8000/redoc for the ReDoc documentation interface. ReDoc presents the same information in a three-panel layout:

  • Left panel — Navigation menu with all endpoints
  • Center panel — Endpoint details, parameters, and descriptions
  • Right panel — Request/response examples and code snippets

ReDoc is often preferred for external-facing API documentation because it is cleaner and more readable. Many teams use Swagger UI for internal development and testing, and ReDoc for public documentation.

The OpenAPI Schema

Both documentation interfaces are powered by the OpenAPI schema, which FastAPI generates automatically. You can access the raw schema at http://127.0.0.1:8000/openapi.json:

curl http://127.0.0.1:8000/openapi.json | python3 -m json.tool

This returns a JSON document following the OpenAPI 3.1 specification. This schema can be used for:

  • Code generation — Automatically generate client SDKs in TypeScript, Java, Go, or any language using tools like openapi-generator
  • API testing — Import the schema into Postman, Insomnia, or other API testing tools
  • Contract testing — Validate that your API conforms to its specification
  • API gateways — Configure AWS API Gateway, Kong, or Apigee from the schema

Customizing Documentation

You can enrich the auto-generated documentation by adding metadata to your FastAPI instance and route handlers:

from fastapi import FastAPI

app = FastAPI(
    title="Tutorial API",
    description="""
## Tutorial API for learning FastAPI

This API demonstrates:
* **CRUD operations** on items
* **User management** with authentication
* **Search functionality** with pagination

### Authentication
Most endpoints require a valid JWT token in the Authorization header.
    """,
    version="2.0.0",
    terms_of_service="https://example.com/terms",
    contact={
        "name": "API Support",
        "url": "https://example.com/support",
        "email": "support@example.com",
    },
    license_info={
        "name": "MIT License",
        "url": "https://opensource.org/licenses/MIT",
    },
)

@app.get(
    "/items/{item_id}",
    summary="Get a single item",
    description="Retrieve an item by its unique integer ID. Returns 404 if not found.",
    response_description="The requested item",
    tags=["Items"],
    deprecated=False,
)
async def read_item(item_id: int):
    return {"item_id": item_id}

The tags parameter groups endpoints in the documentation. The summary appears as the endpoint title, and description provides detailed explanation. You can also use docstrings on your handler functions — FastAPI will use them as the endpoint description if no explicit description parameter is provided.

Tip: You can disable the documentation endpoints in production if needed: app = FastAPI(docs_url=None, redoc_url=None). You can also change their URLs: app = FastAPI(docs_url="/api/docs", redoc_url="/api/redoc").

Python Type Hints Primer

Type hints are the foundation of FastAPI. If you are not comfortable with Python type hints, this section will give you everything you need to know. If you are already experienced with type hints, you can skip to the next section.

What Are Type Hints?

Type hints (also called type annotations) were introduced in Python 3.5 via PEP 484. They allow you to declare the expected types of variables, function parameters, and return values. Python itself does not enforce these types at runtime — it is still a dynamically typed language. But tools like mypy, Pyright, and FastAPI use them extensively.

# Without type hints
def greet(name):
    return f"Hello, {name}"

# With type hints
def greet(name: str) -> str:
    return f"Hello, {name}"

The type-hinted version tells us (and our tools) that name should be a string and the function returns a string. The runtime behavior is identical.

Basic Types

# Primitive types
name: str = "FastAPI"
age: int = 30
price: float = 19.99
is_active: bool = True

# None type
result: None = None

# Function with type hints
def calculate_total(price: float, quantity: int, tax_rate: float = 0.08) -> float:
    """Calculate total price with tax."""
    subtotal = price * quantity
    return subtotal * (1 + tax_rate)

Collection Types

For collections, you specify both the container type and the type of elements it contains:

# Python 3.9+ syntax (recommended)
names: list[str] = ["Alice", "Bob", "Charlie"]
scores: dict[str, int] = {"Alice": 95, "Bob": 87}
unique_ids: set[int] = {1, 2, 3}
coordinates: tuple[float, float] = (40.7128, -74.0060)

# Python 3.7-3.8 syntax (use typing module)
from typing import List, Dict, Set, Tuple

names: List[str] = ["Alice", "Bob", "Charlie"]
scores: Dict[str, int] = {"Alice": 95, "Bob": 87}
unique_ids: Set[int] = {1, 2, 3}
coordinates: Tuple[float, float] = (40.7128, -74.0060)
Note: Starting with Python 3.9, you can use built-in types directly (list[str] instead of List[str]). FastAPI works with both syntaxes. We recommend the newer syntax if your project targets Python 3.9+.

Optional and Union Types

# Python 3.10+ syntax
def find_user(user_id: int) -> dict | None:
    """Returns user dict or None if not found."""
    ...

def process_input(value: str | int) -> str:
    """Accepts either a string or integer."""
    return str(value)

# Python 3.7-3.9 syntax
from typing import Optional, Union

def find_user(user_id: int) -> Optional[dict]:
    """Returns user dict or None if not found."""
    ...

def process_input(value: Union[str, int]) -> str:
    """Accepts either a string or integer."""
    return str(value)

Optional[X] is equivalent to Union[X, None] or X | None. It means the value can be of type X or None.

Why Type Hints Matter in FastAPI

In most Python code, type hints are informational — they help your IDE and static analysis tools but have no runtime effect. FastAPI changes this equation. FastAPI uses type hints at runtime to:

  1. Parse request data — If you declare a path parameter as int, FastAPI converts the string from the URL to an integer
  2. Validate input — If conversion fails (e.g., “abc” cannot become an int), FastAPI returns a 422 validation error
  3. Generate documentation — The OpenAPI schema reflects your type hints, showing exact parameter types and constraints
  4. Serialize responses — Response models filter and format output based on type annotations
from fastapi import FastAPI

app = FastAPI()

@app.get("/users/{user_id}")
async def get_user(
    user_id: int,           # Path param: parsed as int, 422 if invalid
    include_posts: bool = False,  # Query param: parsed as bool, default False
    limit: int = 10,        # Query param: parsed as int, default 10
) -> dict:                  # Return type hint for documentation
    """
    FastAPI uses these type hints to:
    1. Extract user_id from the URL path and convert to int
    2. Extract include_posts and limit from query string
    3. Validate all types and return 422 on failure
    4. Document all parameters with their types in /docs
    """
    user = {"id": user_id, "name": f"User {user_id}"}
    if include_posts:
        user["posts"] = [{"title": f"Post {i}"} for i in range(limit)]
    return user

With this single function definition, FastAPI knows that /users/abc is invalid (not an integer), /users/42?include_posts=true&limit=5 is valid, and the docs should show all three parameters with their types and defaults.


Path Operations

In FastAPI, a “path operation” is the combination of an HTTP method and a URL path. This is the fundamental building block of any API. Let us explore how FastAPI handles HTTP methods and how to use path operation decorators.

HTTP Methods Overview

HTTP defines several request methods (also called “verbs”) that indicate the desired action to perform on a resource:

Method Purpose Has Request Body? Idempotent? Safe? FastAPI Decorator
GET Read / retrieve a resource No (typically) Yes Yes @app.get()
POST Create a new resource Yes No No @app.post()
PUT Replace a resource entirely Yes Yes No @app.put()
PATCH Partially update a resource Yes No No @app.patch()
DELETE Remove a resource No (typically) Yes No @app.delete()
OPTIONS Describe communication options No Yes Yes @app.options()
HEAD GET without response body No Yes Yes @app.head()
Terminology: Idempotent means making the same request multiple times has the same effect as making it once. Safe means the request does not modify the resource. GET is both safe and idempotent. POST is neither — posting twice creates two resources. PUT is idempotent but not safe — it modifies the resource, but doing it twice has the same result as doing it once.

Path Operation Decorators in Practice

Here is a complete CRUD (Create, Read, Update, Delete) example for a “books” resource:

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

# In-memory "database" for demonstration
books_db: dict[int, dict] = {}
next_id: int = 1


class BookCreate(BaseModel):
    title: str
    author: str
    year: int
    isbn: str | None = None


class BookUpdate(BaseModel):
    title: str | None = None
    author: str | None = None
    year: int | None = None
    isbn: str | None = None


# CREATE - POST
@app.post("/books", status_code=201)
async def create_book(book: BookCreate):
    """Create a new book."""
    global next_id
    book_data = book.model_dump()
    book_data["id"] = next_id
    books_db[next_id] = book_data
    next_id += 1
    return book_data


# READ ALL - GET
@app.get("/books")
async def list_books(skip: int = 0, limit: int = 20):
    """List all books with pagination."""
    all_books = list(books_db.values())
    return all_books[skip : skip + limit]


# READ ONE - GET
@app.get("/books/{book_id}")
async def get_book(book_id: int):
    """Get a single book by ID."""
    if book_id not in books_db:
        raise HTTPException(status_code=404, detail="Book not found")
    return books_db[book_id]


# FULL UPDATE - PUT
@app.put("/books/{book_id}")
async def replace_book(book_id: int, book: BookCreate):
    """Replace a book entirely."""
    if book_id not in books_db:
        raise HTTPException(status_code=404, detail="Book not found")
    book_data = book.model_dump()
    book_data["id"] = book_id
    books_db[book_id] = book_data
    return book_data


# PARTIAL UPDATE - PATCH
@app.patch("/books/{book_id}")
async def update_book(book_id: int, book: BookUpdate):
    """Partially update a book."""
    if book_id not in books_db:
        raise HTTPException(status_code=404, detail="Book not found")
    stored_book = books_db[book_id]
    update_data = book.model_dump(exclude_unset=True)
    stored_book.update(update_data)
    return stored_book


# DELETE - DELETE
@app.delete("/books/{book_id}", status_code=204)
async def delete_book(book_id: int):
    """Delete a book."""
    if book_id not in books_db:
        raise HTTPException(status_code=404, detail="Book not found")
    del books_db[book_id]
    return None

Key patterns to notice:

  • status_code=201 on the POST handler — returns 201 Created instead of the default 200 OK
  • status_code=204 on the DELETE handler — returns 204 No Content (standard for successful deletes)
  • HTTPException — FastAPI’s built-in exception class for returning HTTP error responses. The status_code and detail are sent to the client as a JSON error response
  • Separate models for create vs updateBookCreate requires all fields; BookUpdate makes all fields optional for partial updates
  • model_dump(exclude_unset=True) — Only includes fields that were explicitly sent in the request, so PATCH works correctly

Path Parameters

Path parameters are variables embedded in the URL path. FastAPI extracts them automatically:

from fastapi import FastAPI
from enum import Enum

app = FastAPI()

# Basic path parameter with type validation
@app.get("/users/{user_id}")
async def get_user(user_id: int):
    return {"user_id": user_id}

# Multiple path parameters
@app.get("/organizations/{org_id}/teams/{team_id}")
async def get_team(org_id: int, team_id: int):
    return {"org_id": org_id, "team_id": team_id}

# Enum path parameter - restricts to predefined values
class ModelName(str, Enum):
    alexnet = "alexnet"
    resnet = "resnet"
    lenet = "lenet"

@app.get("/models/{model_name}")
async def get_model(model_name: ModelName):
    # Only accepts "alexnet", "resnet", or "lenet"
    # Returns 422 for any other value
    return {"model": model_name, "message": f"Selected {model_name.value}"}

# File path parameter (captures the rest of the path)
@app.get("/files/{file_path:path}")
async def read_file(file_path: str):
    # Matches /files/home/user/document.txt
    return {"file_path": file_path}

Query Parameters

Any function parameter that is not a path parameter is automatically treated as a query parameter:

from fastapi import FastAPI, Query

app = FastAPI()

# Basic query parameters
@app.get("/items")
async def list_items(
    skip: int = 0,      # Optional with default
    limit: int = 10,    # Optional with default
    q: str | None = None  # Optional, can be None
):
    # URL: /items?skip=20&limit=5&q=search_term
    return {"skip": skip, "limit": limit, "q": q}

# Required query parameter (no default value)
@app.get("/search")
async def search(q: str):
    # URL: /search?q=python  (q is required)
    return {"query": q}

# Advanced query validation with Query()
@app.get("/products")
async def list_products(
    q: str | None = Query(
        default=None,
        min_length=3,
        max_length=50,
        pattern=r"^[a-zA-Z0-9\s]+$",
        title="Search query",
        description="Search products by name. Minimum 3 characters.",
        examples=["laptop", "wireless mouse"],
    ),
    page: int = Query(default=1, ge=1, description="Page number (starting from 1)"),
    size: int = Query(default=20, ge=1, le=100, description="Items per page (max 100)"),
    sort_by: str = Query(default="name", pattern=r"^(name|price|rating)$"),
):
    return {
        "query": q,
        "page": page,
        "size": size,
        "sort_by": sort_by,
    }

The Query() function provides additional validation and metadata. The ge (greater than or equal), le (less than or equal), gt (greater than), lt (less than) constraints work for numeric parameters. The min_length, max_length, and pattern constraints work for string parameters. All of these constraints appear automatically in the generated documentation.


Request & Response Cycle

Understanding how FastAPI processes a request from arrival to response is essential for debugging, performance optimization, and middleware development. Here is the complete lifecycle of an HTTP request in a FastAPI application.

The ASGI Standard

FastAPI is an ASGI application. ASGI (Asynchronous Server Gateway Interface) is the successor to WSGI, designed for async Python frameworks. The key differences:

Feature WSGI ASGI
Concurrency Model Synchronous (one request per thread) Asynchronous (many requests per thread)
Protocol Support HTTP only HTTP, WebSocket, HTTP/2
Long-lived Connections Not supported Native support
Background Tasks Require external tools (Celery) Built-in support
Frameworks Flask, Django FastAPI, Starlette, Django 3.0+
Servers Gunicorn, uWSGI Uvicorn, Hypercorn, Daphne

Request Lifecycle Step by Step

When a client sends an HTTP request to your FastAPI application, here is exactly what happens:

# Visualizing the request lifecycle

# Step 1: Client sends HTTP request
# GET /users/42?include_email=true HTTP/1.1
# Host: api.example.com
# Authorization: Bearer eyJhbGci...

# Step 2: Uvicorn (ASGI server) receives the raw TCP connection
#   - Parses HTTP protocol
#   - Creates ASGI scope dictionary
#   - Passes to the ASGI application (FastAPI/Starlette)

# Step 3: Starlette middleware stack processes the request
#   - CORS middleware
#   - GZip middleware
#   - Authentication middleware
#   - Custom middleware
#   (Each middleware can modify request/response or short-circuit)

# Step 4: Starlette router matches the URL path to a route
#   - Finds the handler for GET /users/{user_id}
#   - Extracts path parameters: user_id = "42"

# Step 5: FastAPI dependency injection
#   - Resolves dependencies declared with Depends()
#   - Runs authentication, database session creation, etc.

# Step 6: FastAPI request parsing and validation
#   - Converts path params: user_id = int("42") = 42
#   - Parses query params: include_email = bool("true") = True
#   - Validates request body against Pydantic model (if applicable)
#   - Returns 422 if validation fails

# Step 7: Your handler function executes
#   async def get_user(user_id: int, include_email: bool = False):
#       user = await db.get_user(user_id)
#       return user

# Step 8: FastAPI response processing
#   - Serializes return value to JSON
#   - Applies response_model filtering (if defined)
#   - Sets status code and headers
#   - Validates response against response_model (in debug mode)

# Step 9: Middleware stack processes the response (in reverse order)
#   - GZip compresses response body
#   - CORS adds Access-Control headers

# Step 10: Uvicorn sends HTTP response to client
#   HTTP/1.1 200 OK
#   Content-Type: application/json
#   {"id": 42, "name": "Alice", "email": "alice@example.com"}

Middleware

Middleware is code that runs before every request and after every response. It wraps the entire application like layers of an onion. Here is how to add middleware in FastAPI:

import time
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

# Built-in CORS middleware
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000", "https://myapp.com"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# Custom middleware: request timing
@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
    """Measure and log request processing time."""
    start_time = time.perf_counter()

    response = await call_next(request)

    process_time = time.perf_counter() - start_time
    response.headers["X-Process-Time"] = f"{process_time:.4f}"
    print(f"{request.method} {request.url.path} - {process_time:.4f}s")

    return response

# Custom middleware: request ID tracking
import uuid

@app.middleware("http")
async def add_request_id(request: Request, call_next):
    """Add a unique request ID to every request/response."""
    request_id = str(uuid.uuid4())
    request.state.request_id = request_id

    response = await call_next(request)
    response.headers["X-Request-ID"] = request_id

    return response

@app.get("/")
async def root(request: Request):
    return {
        "message": "Hello",
        "request_id": request.state.request_id
    }

Middleware execution order matters. Middleware is applied in the order it is added, but wraps the application like nested layers:

  1. Request arrives → first middleware added runs first
  2. Request passes through all middleware to the handler
  3. Response returns → last middleware added runs first (reverse order)

The Request Object

FastAPI gives you access to the raw Starlette Request object when you need low-level control:

from fastapi import FastAPI, Request

app = FastAPI()

@app.get("/request-info")
async def request_info(request: Request):
    """Inspect the raw request object."""
    return {
        "method": request.method,
        "url": str(request.url),
        "base_url": str(request.base_url),
        "path": request.url.path,
        "query_params": dict(request.query_params),
        "path_params": request.path_params,
        "headers": dict(request.headers),
        "client_host": request.client.host if request.client else None,
        "client_port": request.client.port if request.client else None,
        "cookies": request.cookies,
    }

Response Types

FastAPI supports multiple response types beyond simple JSON:

from fastapi import FastAPI
from fastapi.responses import (
    JSONResponse,
    HTMLResponse,
    PlainTextResponse,
    RedirectResponse,
    StreamingResponse,
    FileResponse,
)

app = FastAPI()

# Default: JSON response (automatic)
@app.get("/json")
async def json_response():
    return {"message": "This is JSON"}  # Automatically wrapped in JSONResponse

# Explicit JSON with custom status code and headers
@app.get("/custom-json")
async def custom_json():
    return JSONResponse(
        content={"message": "Custom response"},
        status_code=201,
        headers={"X-Custom-Header": "value"},
    )

# HTML response
@app.get("/html", response_class=HTMLResponse)
async def html_response():
    return "<h1>Hello from FastAPI</h1><p>This is HTML.</p>"

# Plain text response
@app.get("/text", response_class=PlainTextResponse)
async def text_response():
    return "Just plain text"

# Redirect response
@app.get("/old-page")
async def redirect():
    return RedirectResponse(url="/new-page", status_code=301)

# File download
@app.get("/download")
async def download_file():
    return FileResponse(
        path="report.pdf",
        filename="monthly-report.pdf",
        media_type="application/pdf",
    )

# Streaming response (for large data)
@app.get("/stream")
async def stream_data():
    async def generate():
        for i in range(100):
            yield f"data: chunk {i}\n\n"
    return StreamingResponse(generate(), media_type="text/event-stream")

Project Structure Best Practices

A “Hello World” in a single file is fine for learning, but real applications need proper organization. Here is how professional FastAPI projects are structured.

Small Project (Single Module)

For small APIs with fewer than 10 endpoints:

fastapi-project/
├── main.py              # Application entry point, all routes
├── models.py            # Pydantic models (schemas)
├── database.py          # Database connection and session
├── requirements.txt     # Dependencies
├── .env                 # Environment variables (never commit)
├── .gitignore
└── tests/
    ├── __init__.py
    └── test_main.py

Medium Project (Package with Routers)

For APIs with 10-50 endpoints, organized by domain:

fastapi-project/
├── app/
│   ├── __init__.py
│   ├── main.py           # Application factory, startup/shutdown events
│   ├── config.py          # Settings and configuration
│   ├── dependencies.py    # Shared dependencies (auth, db session)
│   ├── database.py        # Database engine and session setup
│   ├── models/            # SQLAlchemy ORM models
│   │   ├── __init__.py
│   │   ├── user.py
│   │   └── item.py
│   ├── schemas/           # Pydantic request/response models
│   │   ├── __init__.py
│   │   ├── user.py
│   │   └── item.py
│   ├── routers/           # API route handlers (grouped by domain)
│   │   ├── __init__.py
│   │   ├── users.py
│   │   └── items.py
│   ├── services/          # Business logic layer
│   │   ├── __init__.py
│   │   ├── user_service.py
│   │   └── item_service.py
│   └── middleware/        # Custom middleware
│       ├── __init__.py
│       └── logging.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py        # Shared fixtures
│   ├── test_users.py
│   └── test_items.py
├── alembic/               # Database migrations
│   ├── env.py
│   └── versions/
├── alembic.ini
├── requirements.txt
├── .env
└── .gitignore

Large Project (Domain-Driven Design)

For large applications with many domains and teams:

fastapi-project/
├── app/
│   ├── __init__.py
│   ├── main.py
│   ├── core/              # Core application components
│   │   ├── __init__.py
│   │   ├── config.py      # Pydantic Settings for configuration
│   │   ├── security.py    # JWT, password hashing, OAuth2
│   │   ├── database.py    # Database engine, session, base model
│   │   ├── dependencies.py # Shared dependencies
│   │   └── exceptions.py  # Custom exception handlers
│   ├── features/          # Feature modules (domain-driven)
│   │   ├── auth/
│   │   │   ├── __init__.py
│   │   │   ├── router.py
│   │   │   ├── schemas.py
│   │   │   ├── models.py
│   │   │   ├── service.py
│   │   │   └── dependencies.py
│   │   ├── users/
│   │   │   ├── __init__.py
│   │   │   ├── router.py
│   │   │   ├── schemas.py
│   │   │   ├── models.py
│   │   │   ├── service.py
│   │   │   └── repository.py
│   │   └── products/
│   │       ├── __init__.py
│   │       ├── router.py
│   │       ├── schemas.py
│   │       ├── models.py
│   │       ├── service.py
│   │       └── repository.py
│   └── common/            # Shared utilities
│       ├── __init__.py
│       ├── pagination.py
│       ├── responses.py
│       └── validators.py
├── tests/
├── alembic/
├── docker/
│   ├── Dockerfile
│   └── docker-compose.yml
├── scripts/
│   ├── seed_db.py
│   └── create_admin.py
├── pyproject.toml         # Modern Python project config
├── requirements/
│   ├── base.txt
│   ├── dev.txt
│   └── prod.txt
├── .env.example
└── .gitignore

Implementing Routers

The APIRouter is FastAPI’s way of organizing endpoints into separate modules. Think of it as a mini FastAPI application that you mount on your main app. Here is a practical example:

# app/routers/users.py
from fastapi import APIRouter, HTTPException, Depends
from app.schemas.user import UserCreate, UserResponse, UserUpdate
from app.services.user_service import UserService
from app.dependencies import get_db, get_current_user

router = APIRouter(
    prefix="/users",
    tags=["Users"],
    responses={404: {"description": "User not found"}},
)

@router.get("/", response_model=list[UserResponse])
async def list_users(
    skip: int = 0,
    limit: int = 20,
    db=Depends(get_db),
):
    """List all users with pagination."""
    service = UserService(db)
    return await service.get_users(skip=skip, limit=limit)

@router.get("/{user_id}", response_model=UserResponse)
async def get_user(user_id: int, db=Depends(get_db)):
    """Get a single user by ID."""
    service = UserService(db)
    user = await service.get_user(user_id)
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    return user

@router.post("/", response_model=UserResponse, status_code=201)
async def create_user(user: UserCreate, db=Depends(get_db)):
    """Create a new user."""
    service = UserService(db)
    return await service.create_user(user)

@router.put("/{user_id}", response_model=UserResponse)
async def update_user(
    user_id: int,
    user: UserUpdate,
    db=Depends(get_db),
    current_user=Depends(get_current_user),
):
    """Update a user. Requires authentication."""
    service = UserService(db)
    return await service.update_user(user_id, user)

@router.delete("/{user_id}", status_code=204)
async def delete_user(
    user_id: int,
    db=Depends(get_db),
    current_user=Depends(get_current_user),
):
    """Delete a user. Requires authentication."""
    service = UserService(db)
    await service.delete_user(user_id)
# app/routers/items.py
from fastapi import APIRouter, Depends
from app.schemas.item import ItemCreate, ItemResponse
from app.dependencies import get_db

router = APIRouter(
    prefix="/items",
    tags=["Items"],
)

@router.get("/", response_model=list[ItemResponse])
async def list_items(db=Depends(get_db)):
    """List all items."""
    ...

@router.post("/", response_model=ItemResponse, status_code=201)
async def create_item(item: ItemCreate, db=Depends(get_db)):
    """Create a new item."""
    ...
# app/main.py
from fastapi import FastAPI
from app.routers import users, items

app = FastAPI(
    title="My Application API",
    version="1.0.0",
)

# Include routers
app.include_router(users.router)
app.include_router(items.router)

# You can also add a prefix when including
# app.include_router(users.router, prefix="/api/v1")

@app.get("/")
async def root():
    return {"message": "Welcome to the API", "docs": "/docs"}

The result is a clean separation of concerns. Each router file handles a single domain (users, items, etc.), the main application file is short and declarative, and you can easily add new domains by creating a new router file and including it.

Configuration with Pydantic Settings

FastAPI projects should use Pydantic’s BaseSettings for configuration management. It reads from environment variables and .env files with full type validation:

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

class Settings(BaseSettings):
    """Application settings loaded from environment variables."""

    # Application
    app_name: str = "FastAPI Tutorial"
    debug: bool = False
    api_version: str = "v1"

    # Server
    host: str = "0.0.0.0"
    port: int = 8000
    workers: int = 4

    # Database
    database_url: str = "sqlite:///./app.db"

    # Security
    secret_key: str = "change-me-in-production"
    access_token_expire_minutes: int = 30
    algorithm: str = "HS256"

    # CORS
    allowed_origins: list[str] = ["http://localhost:3000"]

    model_config = {
        "env_file": ".env",
        "env_file_encoding": "utf-8",
        "case_sensitive": False,
    }

@lru_cache
def get_settings() -> Settings:
    """Cached settings instance. Call this instead of Settings() directly."""
    return Settings()
# .env file
APP_NAME=My Production API
DEBUG=false
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
SECRET_KEY=your-super-secret-key-here
ALLOWED_ORIGINS=["https://myapp.com","https://admin.myapp.com"]
# Using settings in your application
from fastapi import FastAPI, Depends
from app.core.config import Settings, get_settings

app = FastAPI()

@app.get("/info")
async def info(settings: Settings = Depends(get_settings)):
    return {
        "app_name": settings.app_name,
        "debug": settings.debug,
        "api_version": settings.api_version,
    }
Security Warning: Never commit your .env file to version control. Always add it to .gitignore. Create a .env.example file with placeholder values so other developers know which variables are needed.

Dependency Injection

FastAPI’s dependency injection system is one of its most powerful features. Dependencies are reusable functions that provide shared resources (database sessions, current user, settings) to your route handlers:

# app/dependencies.py
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from app.database import SessionLocal

# Database session dependency
def get_db():
    """Yield a database session, auto-close when done."""
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

# Authentication dependency
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")

async def get_current_user(
    token: str = Depends(oauth2_scheme),
    db: Session = Depends(get_db),
):
    """Validate JWT token and return current user."""
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = decode_token(token)
        user_id = payload.get("sub")
        if user_id is None:
            raise credentials_exception
    except Exception:
        raise credentials_exception

    user = db.query(User).filter(User.id == user_id).first()
    if user is None:
        raise credentials_exception
    return user

# Role-based access dependency
def require_role(required_role: str):
    """Factory that creates a dependency requiring a specific role."""
    async def role_checker(current_user=Depends(get_current_user)):
        if current_user.role != required_role:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f"Role '{required_role}' required",
            )
        return current_user
    return role_checker

# Usage in routes:
# @router.delete("/users/{id}", dependencies=[Depends(require_role("admin"))])
# async def delete_user(id: int): ...

Dependencies can depend on other dependencies, forming a dependency tree. FastAPI resolves this tree automatically, creates instances in the correct order, and handles cleanup (via yield) when the request completes.


Application Lifecycle Events

FastAPI supports startup and shutdown events using the modern lifespan context manager. This is where you initialize and clean up shared resources like database connection pools, HTTP clients, and caches:

from contextlib import asynccontextmanager
from fastapi import FastAPI
import httpx

# Shared resources
http_client: httpx.AsyncClient | None = None

@asynccontextmanager
async def lifespan(app: FastAPI):
    """Manage application lifecycle - startup and shutdown."""
    # --- STARTUP ---
    global http_client
    print("Starting up... initializing resources")

    # Create a shared HTTP client with connection pooling
    http_client = httpx.AsyncClient(
        base_url="https://api.example.com",
        timeout=30.0,
    )

    # Initialize database connection pool
    # await database.connect()

    # Load ML models, warm caches, etc.
    print("Startup complete")

    yield  # Application runs here

    # --- SHUTDOWN ---
    print("Shutting down... cleaning up resources")
    await http_client.aclose()
    # await database.disconnect()
    print("Shutdown complete")

app = FastAPI(lifespan=lifespan)

@app.get("/external-data")
async def get_external_data():
    """Use the shared HTTP client."""
    response = await http_client.get("/data")
    return response.json()
Note: The lifespan context manager replaces the older @app.on_event("startup") and @app.on_event("shutdown") decorators, which are deprecated. Always use lifespan for new projects.

Error Handling

Proper error handling is critical for production APIs. FastAPI provides several mechanisms for handling errors gracefully.

HTTPException

The simplest way to return an HTTP error response:

from fastapi import FastAPI, HTTPException, status

app = FastAPI()

@app.get("/items/{item_id}")
async def get_item(item_id: int):
    if item_id < 0:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Item ID must be positive",
        )

    item = fake_db.get(item_id)
    if not item:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Item with ID {item_id} not found",
            headers={"X-Error": "item-not-found"},
        )

    return item

Custom Exception Handlers

For consistent error formatting across your entire API:

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException

app = FastAPI()

# Custom exception class
class AppException(Exception):
    def __init__(self, status_code: int, detail: str, error_code: str):
        self.status_code = status_code
        self.detail = detail
        self.error_code = error_code

# Handler for custom exceptions
@app.exception_handler(AppException)
async def app_exception_handler(request: Request, exc: AppException):
    return JSONResponse(
        status_code=exc.status_code,
        content={
            "error": {
                "code": exc.error_code,
                "message": exc.detail,
                "path": str(request.url),
            }
        },
    )

# Override default validation error handler for consistent format
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    return JSONResponse(
        status_code=422,
        content={
            "error": {
                "code": "VALIDATION_ERROR",
                "message": "Request validation failed",
                "details": exc.errors(),
            }
        },
    )

# Override default HTTP exception handler
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
    return JSONResponse(
        status_code=exc.status_code,
        content={
            "error": {
                "code": f"HTTP_{exc.status_code}",
                "message": exc.detail,
            }
        },
    )

# Usage
@app.get("/users/{user_id}")
async def get_user(user_id: int):
    user = await find_user(user_id)
    if not user:
        raise AppException(
            status_code=404,
            detail=f"User {user_id} not found",
            error_code="USER_NOT_FOUND",
        )
    return user

This pattern ensures every error response from your API has a consistent structure, making it easier for frontend developers and API consumers to handle errors programmatically.


Running in Production

While this is an introductory tutorial, it is worth understanding the basics of running FastAPI in production. We will cover this topic in depth in the FastAPI – Deployment tutorial later in this series.

Development vs Production

Setting Development Production
Server Command uvicorn main:app --reload gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker
Workers 1 (with reload) 2-4x CPU cores
Auto-reload Enabled Disabled
Debug Mode Enabled Disabled
Docs Enabled (/docs, /redoc) Often disabled or restricted
HTTPS Not needed Required (via reverse proxy)
Logging Console output Structured JSON logging

Quick Production Setup

# Install Gunicorn (Linux/macOS only)
pip install gunicorn

# Run with Gunicorn + Uvicorn workers
gunicorn app.main:app \
    --workers 4 \
    --worker-class uvicorn.workers.UvicornWorker \
    --bind 0.0.0.0:8000 \
    --access-logfile - \
    --error-logfile -
# Dockerfile for FastAPI
# app/Dockerfile

FROM python:3.11-slim

WORKDIR /app

# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy application code
COPY . .

# Run with Uvicorn (for single-container deployments)
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
# Build and run the Docker container
docker build -t fastapi-app .
docker run -p 8000:8000 fastapi-app

Complete Example: Putting It All Together

Let us build a complete, well-structured FastAPI application that demonstrates everything we have covered. This is a task management API with proper project organization:

# Project structure
task-manager/
├── app/
│   ├── __init__.py
│   ├── main.py
│   ├── config.py
│   ├── models.py
│   └── routers/
│       ├── __init__.py
│       └── tasks.py
├── requirements.txt
└── .env
# app/config.py
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    app_name: str = "Task Manager API"
    version: str = "1.0.0"
    debug: bool = True

    model_config = {"env_file": ".env"}

settings = Settings()
# app/models.py
from pydantic import BaseModel, Field
from datetime import 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"

class TaskCreate(BaseModel):
    """Schema for creating a new task."""
    title: str = Field(..., min_length=1, max_length=200, examples=["Write unit tests"])
    description: str | None = Field(None, max_length=2000)
    priority: Priority = Field(default=Priority.medium)
    assignee: str | None = None

class TaskUpdate(BaseModel):
    """Schema for updating an existing task (all fields optional)."""
    title: str | None = Field(None, min_length=1, max_length=200)
    description: str | None = Field(None, max_length=2000)
    priority: Priority | None = None
    status: TaskStatus | None = None
    assignee: str | None = None

class TaskResponse(BaseModel):
    """Schema for task responses."""
    id: int
    title: str
    description: str | None
    priority: Priority
    status: TaskStatus
    assignee: str | None
    created_at: str
    updated_at: str
# app/routers/tasks.py
from fastapi import APIRouter, HTTPException, Query
from app.models import TaskCreate, TaskUpdate, TaskResponse, TaskStatus, Priority
from datetime import datetime

router = APIRouter(prefix="/tasks", tags=["Tasks"])

# In-memory storage (use a real database in production)
tasks_db: dict[int, dict] = {}
next_id: int = 1


@router.get("/", response_model=list[TaskResponse])
async def list_tasks(
    status: TaskStatus | None = Query(None, description="Filter by status"),
    priority: Priority | None = Query(None, description="Filter by priority"),
    assignee: str | None = Query(None, description="Filter by assignee"),
    skip: int = Query(0, ge=0),
    limit: int = Query(20, ge=1, le=100),
):
    """List all tasks with optional filters 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 assignee:
        results = [t for t in results if t["assignee"] == assignee]

    return results[skip : skip + limit]


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


@router.post("/", response_model=TaskResponse, status_code=201)
async def create_task(task: TaskCreate):
    """Create a new task."""
    global next_id
    now = datetime.utcnow().isoformat()

    task_data = {
        "id": next_id,
        **task.model_dump(),
        "status": TaskStatus.todo,
        "created_at": now,
        "updated_at": now,
    }
    tasks_db[next_id] = task_data
    next_id += 1
    return task_data


@router.patch("/{task_id}", response_model=TaskResponse)
async def update_task(task_id: int, task: TaskUpdate):
    """Partially update a task."""
    if task_id not in tasks_db:
        raise HTTPException(status_code=404, detail=f"Task {task_id} not found")

    stored = tasks_db[task_id]
    update_data = task.model_dump(exclude_unset=True)
    stored.update(update_data)
    stored["updated_at"] = datetime.utcnow().isoformat()
    return stored


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


@router.get("/stats/summary")
async def task_stats():
    """Get task statistics."""
    tasks = list(tasks_db.values())
    return {
        "total": len(tasks),
        "by_status": {
            s.value: len([t for t in tasks if t["status"] == s])
            for s in TaskStatus
        },
        "by_priority": {
            p.value: len([t for t in tasks if t["priority"] == p])
            for p in Priority
        },
    }
# app/main.py
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import tasks

@asynccontextmanager
async def lifespan(app: FastAPI):
    """Application lifecycle manager."""
    print(f"Starting {settings.app_name} v{settings.version}")
    yield
    print("Shutting down...")

app = FastAPI(
    title=settings.app_name,
    version=settings.version,
    description="A task management API built with FastAPI",
    lifespan=lifespan,
)

# CORS middleware
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# Request timing middleware
@app.middleware("http")
async def add_process_time(request: Request, call_next):
    start = time.perf_counter()
    response = await call_next(request)
    duration = time.perf_counter() - start
    response.headers["X-Process-Time"] = f"{duration:.4f}"
    return response

# Include routers
app.include_router(tasks.router)

@app.get("/", tags=["Health"])
async def root():
    """API root - health check and documentation links."""
    return {
        "app": settings.app_name,
        "version": settings.version,
        "status": "healthy",
        "docs": "/docs",
        "redoc": "/redoc",
    }

@app.get("/health", tags=["Health"])
async def health():
    """Detailed health check."""
    return {"status": "healthy", "version": settings.version}
# requirements.txt
fastapi[standard]>=0.115.0
pydantic-settings>=2.0.0
uvicorn[standard]>=0.30.0

Run the application:

# From the project root
uvicorn app.main:app --reload

# Test the API
curl -X POST http://127.0.0.1:8000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn FastAPI", "priority": "high"}'

curl http://127.0.0.1:8000/tasks
curl http://127.0.0.1:8000/tasks?status=todo&priority=high
curl http://127.0.0.1:8000/tasks/stats/summary
curl http://127.0.0.1:8000/docs

This example demonstrates all the concepts from this tutorial: application setup, routers, Pydantic models, path parameters, query parameters, middleware, lifecycle events, error handling, and proper project structure.


Key Takeaways

Here is a summary of everything we covered in this introduction to FastAPI:

  1. FastAPI is built on Starlette and Pydantic. Starlette provides the high-performance ASGI foundation, and Pydantic provides automatic data validation and serialization. Understanding these two libraries helps you understand FastAPI at a deeper level.
  2. Performance is exceptional. FastAPI’s async-first architecture and Uvicorn’s event loop deliver throughput comparable to Node.js and Go. For I/O-bound workloads (database queries, external API calls), FastAPI significantly outperforms Flask and Django.
  3. Type hints drive everything. FastAPI uses Python type hints not just for documentation, but for request parsing, data validation, response serialization, and API documentation generation. Learning type hints well is the most valuable investment you can make when working with FastAPI.
  4. Documentation is automatic. Swagger UI and ReDoc are generated from your code with zero configuration. Every parameter, model, and constraint you define appears in the documentation immediately. Your docs never drift out of sync with your code.
  5. Path operations are the building blocks. Every endpoint is defined by an HTTP method decorator (@app.get, @app.post, etc.) and a handler function. Path parameters, query parameters, and request bodies are all handled through function parameters and type hints.
  6. Routers enable modular organization. Use APIRouter to split your application into domain-specific modules. Each router handles a single concern and is included in the main application.
  7. Dependency injection is powerful. FastAPI’s Depends() system provides reusable, composable dependencies for database sessions, authentication, authorization, and any shared logic.
  8. Error handling should be consistent. Use HTTPException for simple errors and custom exception handlers for consistent error formatting across your entire API.
  9. Project structure matters. Start simple and evolve. A single-file application is fine for learning, but production applications should use the router-based structure with separate layers for schemas, services, and data access.
  10. ASGI is the future of Python web development. ASGI supports async I/O, WebSockets, HTTP/2, and long-lived connections. If you are starting a new Python web project today, ASGI frameworks like FastAPI are the modern choice.

What is Next

In the next tutorial, FastAPI – Routes & Request Handling, we will dive deep into:

  • Advanced path parameters and path converters
  • Query parameter validation with Query()
  • Request body handling with Pydantic models
  • Headers, cookies, and form data
  • File uploads (single and multiple)
  • Request validation and custom validators
  • Response models and status codes

References




Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Leave a Reply

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