What is FastAPI?
FastAPI is a modern Python web framework for building APIs. Its defining idea is that your type hints are the contract. Declare a parameter as int, a body as a Pydantic model, or a return type as ItemOut, and the framework validates the request, serialises the response and writes the documentation — all from that one declaration.
It is built on two libraries you should know by name. Starlette provides the ASGI toolkit: routing, middleware and the request/response cycle. Pydantic provides the validation and serialisation. FastAPI is the layer that combines them, adds dependency injection, and generates an OpenAPI schema at startup.
The result is that a small, readable function gives you a validated endpoint with interactive docs. The magic is real, but it is mechanical, and understanding the mechanics is what this guide is about.
Path operations
A path operation is a function bound to an HTTP method and a URL. The decorator names the method, and the function signature tells FastAPI what to expect.
# main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def root():
return {"message": "Hello, world"}
@app.get("/items/{item_id}")
async def read_item(item_id: int, q: str | None = None):
return {"item_id": item_id, "q": q}
The {item_id} segment is a path parameter, and its int annotation means FastAPI converts the string and returns a 422 automatically if it cannot. Anything not in the path is treated as a query parameter, and a default of None makes it optional. There is no manual parsing and no request.args.
Group related operations with an APIRouter, which can carry a prefix and tags for the docs.
from fastapi import APIRouter
router = APIRouter(prefix="/items", tags=["items"])
@router.get("")
async def list_items():
return []
@router.post("", status_code=201)
async def create_item():
return {}
Then include the router in the app: app.include_router(router). That is how a FastAPI project stays modular.
Pydantic models
A Pydantic model is a class that inherits from BaseModel. It parses input, coerces compatible types, applies constraints and raises a clear validation error when something is wrong.
# app/schemas.py
from datetime import datetime
from pydantic import BaseModel, Field
class ItemBase(BaseModel):
name: str = Field(min_length=1, max_length=120)
price: float = Field(gt=0)
tags: list[str] = []
class ItemCreate(ItemBase):
pass
class ItemOut(ItemBase):
id: int
created_at: datetime
model_config = {"from_attributes": True}
Field adds constraints and metadata. model_config = {"from_attributes": True} lets Pydantic read attributes from an ORM object, so you can return a database row directly and have it serialised. Separating ItemCreate from ItemOut is deliberate: the input model has no id, and the output model can expose fields the client may not set.
Use a Pydantic model as the body type and FastAPI parses, validates and hands you a typed object.
@app.post("/items", response_model=ItemOut, status_code=201)
async def create_item(item: ItemCreate):
return await repo.create(item)
If the body is invalid, FastAPI returns a 422 with a list of the exact fields that failed. You did not write a single validation line.
Request bodies and validation
FastAPI distinguishes the sources of data by position and type:
- Path parameters come from the URL.
- Query parameters are everything else in the signature.
- A Pydantic model is the request body.
Header,Cookie,FormandFilemark those special inputs.
from fastapi import Header, Query
@app.get("/search")
async def search(
q: str = Query(min_length=1, max_length=50),
limit: int = Query(default=20, ge=1, le=100),
user_agent: str | None = Header(default=None),
):
return {"q": q, "limit": limit, "user_agent": user_agent}
Constraints on query parameters are enforced the same way as on models, and the generated docs show the limits. This consistency is the reason FastAPI code reads so compactly.
Automatic documentation
Because the schema is generated from the code, documentation is never out of date. Start the app and open /docs for Swagger UI, where you can call endpoints directly, or /redoc for a cleaner reference.
app = FastAPI(
title="Inventory API",
version="1.0.0",
description="Items, stock levels and orders.",
)
You can add summaries, descriptions and examples to path operations and fields, and they appear in the docs. For frontend teams, the same schema can generate typed clients, so the API and its consumers stay in sync. This is the payoff of treating types as the contract.
Dependencies
A dependency is a function that FastAPI calls before your endpoint and whose return value is injected. Depends() is the mechanism, and it composes.
from fastapi import Depends, HTTPException
async def get_session() -> AsyncSession:
async with SessionLocal() as session:
yield session
async def get_current_user(
token: str = Depends(get_token),
session: AsyncSession = Depends(get_session),
) -> User:
user = await authenticate(session, token)
if user is None:
raise HTTPException(status_code=401, detail="Not authenticated")
return user
@app.get("/me", response_model=UserOut)
async def read_me(user: User = Depends(get_current_user)):
return user
Dependencies can be sync or async, can use yield to run cleanup after the response, and are cached within a request, so get_session is called once even if several dependencies need it. They are the idiomatic home for database sessions, authentication, pagination and feature flags.
Async versus sync
FastAPI supports both def and async def endpoints, and the choice matters.
- An
async defendpoint runs on the event loop. Every I/O call inside it must be awaited from an async library, or it blocks the loop for every other request. - A plain
defendpoint is run in a thread pool, so blocking calls are safe but each request holds a thread.
# Async: use an async driver and await it
@app.get("/items")
async def list_items(session: AsyncSession = Depends(get_session)):
result = await session.execute(select(Item))
return result.scalars().all()
# Sync: blocking code is fine; FastAPI offloads it
@app.get("/report")
def build_report():
return generate_report() # CPU or blocking I/O
The rule is simple: async all the way, or sync all the way. Mixing a blocking call into an async def endpoint is the most common performance bug in FastAPI, and it is invisible until traffic arrives.
Errors and status codes
Raise HTTPException to return an error with a status code and a JSON detail. FastAPI turns it into a response and documents the status code.
from fastapi import HTTPException, status
@app.get("/items/{item_id}", response_model=ItemOut)
async def read_item(item_id: int):
item = await repo.get(item_id)
if item is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Item not found",
)
return item
For a consistent error shape across the API, register exception handlers that convert framework and domain errors into one envelope. Keep the detail field stable; clients will parse it.
Background tasks
BackgroundTasks runs work after the response is sent, on the same process. It is the lightweight option for sending an email or writing an audit row.
from fastapi import BackgroundTasks
def send_receipt(email: str) -> None:
...
@app.post("/orders", status_code=201)
async def create_order(
order: OrderCreate,
tasks: BackgroundTasks,
):
saved = await repo.create(order)
tasks.add_task(send_receipt, saved.email)
return saved
Background tasks are not a job queue. They run in-process, so they are lost if the worker restarts and they compete with request handling. For anything durable, hand off to Celery, RQ or a managed queue instead.
Databases
FastAPI has no database layer, so you bring your own. The common choices are SQLAlchemy with an async engine or SQLModel, which combines SQLAlchemy with Pydantic. The dependency pattern supplies the session and closes it after the request.
# app/db.py
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def get_session() -> AsyncIterator[AsyncSession]:
async with SessionLocal() as session:
yield session
@router.post("", response_model=ItemOut, status_code=201)
async def create_item(
payload: ItemCreate,
session: AsyncSession = Depends(get_session),
):
item = Item(**payload.model_dump())
session.add(item)
await session.commit()
await session.refresh(item)
return item
Use expire_on_commit=False so objects remain usable after the commit, and keep transactions inside the request. If you prefer a synchronous driver, use plain def endpoints so FastAPI can offload them to the thread pool.
Security
FastAPI ships the building blocks for OAuth2 and JWT without prescribing a full auth system. OAuth2PasswordBearer extracts the token, and python-jose or pyjwt verifies it.
from fastapi.security import OAuth2PasswordBearer
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
@app.get("/users/me", response_model=UserOut)
async def read_me(
token: str = Depends(oauth2_scheme),
session: AsyncSession = Depends(get_session),
):
user = await get_user_from_token(session, token)
if user is None:
raise HTTPException(status_code=401, detail="Invalid token")
return user
Store password hashes with passlib or argon2-cffi, never plaintext. Hash and verify outside the endpoint, keep secrets in settings, and add rate limiting and CORS deliberately. FastAPI gives you the tools; the policy is still yours to define.
Configuration and settings
Keep configuration in one typed object instead of reading environment variables throughout the code. pydantic-settings validates the environment at startup and gives you the same type safety as your models.
# app/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_")
database_url: str
secret_key: str
access_token_minutes: int = 30
settings = Settings()
from functools import lru_cache
from app.config import Settings
@lru_cache
def get_settings() -> Settings:
return Settings()
@app.get("/info")
async def info(settings: Settings = Depends(get_settings)):
return {"token_minutes": settings.access_token_minutes}
Because settings are injected like any other dependency, tests can override them with app.dependency_overrides[get_settings]. Missing required variables fail at startup rather than at the first request, which is exactly when you want to find out.
Testing
TestClient wraps the app in a WSGI-like client and makes real requests in-process, including validation and dependency overrides.
# tests/test_items.py
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_create_item():
response = client.post("/items", json={"name": "Cup", "price": 9.5})
assert response.status_code == 201
assert response.json()["name"] == "Cup"
def test_invalid_price():
response = client.post("/items", json={"name": "Cup", "price": -1})
assert response.status_code == 422
Use app.dependency_overrides to swap the database session for a test one, and write async tests with httpx.AsyncClient when you need to exercise async endpoints directly. Because dependencies are explicit, replacing them in tests is straightforward.
Best practices
- Declare request and response models explicitly; never accept or return raw dictionaries.
- Keep
async defendpoints fully async, or usedefso FastAPI offloads blocking work. - Put shared logic in
Depends()dependencies instead of repeating it per route. - Split the app with
APIRouterand give each router a prefix and tags. - Add constraints with
FieldandQueryrather than validating by hand. - Raise
HTTPExceptionwith the right status code and a stabledetail. - Override dependencies in tests instead of patching internals.
- Keep secrets in settings objects read from the environment.
Common mistakes
- Calling blocking code inside an
async defendpoint and stalling the event loop. - Returning ORM objects without a
response_model, leaking internal columns. - Forgetting
from_attributes = Trueand wondering why serialisation fails. - Writing one giant
main.pyinstead of routers and modules. - Treating
BackgroundTasksas a durable job queue. - Sharing a single session across requests or across async tasks.
- Validating the same input twice because the Pydantic model was ignored.
- Catching every exception and returning 200 with an error body.
Where to go next
FastAPI turns type hints into a validated, documented API, which is why it has become the default for new Python services. To compare the trade-off, read the Flask guide for the minimal synchronous approach and Django for a full framework with an ORM and admin. Then go deeper on the API contract with OpenAPI and the design rules in REST APIs, and make sure your endpoints are worth documenting by getting the resource model right.