
FastAPI has become the default choice for new Python APIs, and for good reason: automatic OpenAPI docs, real async support, and request validation powered by Pydantic that catches bad input before it touches your business logic. This tutorial builds a working REST API from an empty folder to a database-backed CRUD service with authentication-ready structure.
๐ Table of Contents
Table of Contents
- Project Setup
- Your First Route
- Request and Response Models with Pydantic
- Adding a Database with SQLModel
- Building Full CRUD Endpoints
- Error Handling
- Automatic Docs
- Running in Production
- FAQ
Project Setup
Create a virtual environment and install the core packages:
mkdir fastapi-todo && cd fastapi-todo
python3 -m venv venv
source venv/bin/activate
pip install fastapi uvicorn[standard] sqlmodel
uvicorn is the ASGI server that actually runs your app; sqlmodel combines SQLAlchemy and Pydantic for a database layer that shares models with your API schemas.
Your First Route
Create main.py:
from fastapi import FastAPI
app = FastAPI(title="Todo API")
@app.get("/")
def read_root():
return {"message": "Todo API is running"}
Run it with hot reload:
uvicorn main:app --reload
Visit http://127.0.0.1:8000 and you’ll see the JSON response. Visit /docs and you already have an interactive Swagger UI โ generated from nothing but your function signatures.
Request and Response Models with Pydantic
Instead of manually checking request.json() for missing fields, define a model and let FastAPI validate for you:
from pydantic import BaseModel
class TodoCreate(BaseModel):
title: str
done: bool = False
@app.post("/todos")
def create_todo(todo: TodoCreate):
return {"title": todo.title, "done": todo.done}
Send a request missing title and FastAPI returns a structured 422 error automatically โ no extra code needed. This validation layer is the single biggest reason teams migrate from Flask.
Adding a Database with SQLModel
SQLModel lets one class serve as both your database table and your API schema:
from sqlmodel import SQLModel, Field, create_engine, Session
class Todo(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
title: str
done: bool = False
engine = create_engine("sqlite:///todos.db")
def init_db():
SQLModel.metadata.create_all(engine)
def get_session():
with Session(engine) as session:
yield session
Call init_db() on startup using FastAPI’s lifespan handler:
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
yield
app = FastAPI(title="Todo API", lifespan=lifespan)
Building Full CRUD Endpoints
With the model and session dependency in place, the four CRUD operations are short and readable:
from fastapi import Depends, HTTPException
from sqlmodel import select
@app.post("/todos", response_model=Todo)
def create_todo(todo: Todo, session: Session = Depends(get_session)):
session.add(todo)
session.commit()
session.refresh(todo)
return todo
@app.get("/todos", response_model=list[Todo])
def list_todos(session: Session = Depends(get_session)):
return session.exec(select(Todo)).all()
@app.get("/todos/{todo_id}", response_model=Todo)
def get_todo(todo_id: int, session: Session = Depends(get_session)):
todo = session.get(Todo, todo_id)
if not todo:
raise HTTPException(status_code=404, detail="Todo not found")
return todo
@app.put("/todos/{todo_id}", response_model=Todo)
def update_todo(todo_id: int, data: Todo, session: Session = Depends(get_session)):
todo = session.get(Todo, todo_id)
if not todo:
raise HTTPException(status_code=404, detail="Todo not found")
todo.title = data.title
todo.done = data.done
session.add(todo)
session.commit()
session.refresh(todo)
return todo
@app.delete("/todos/{todo_id}")
def delete_todo(todo_id: int, session: Session = Depends(get_session)):
todo = session.get(Todo, todo_id)
if not todo:
raise HTTPException(status_code=404, detail="Todo not found")
session.delete(todo)
session.commit()
return {"ok": True}
Notice Depends(get_session) โ FastAPI’s dependency injection system. It runs get_session for every request, hands the result to your function, and cleans it up afterward. The same pattern scales to authentication, rate limiting, and pagination without repeating boilerplate in every handler.
Error Handling
HTTPException covers most cases, but for domain-specific errors, register a custom exception handler so responses stay consistent across the API:
from fastapi.responses import JSONResponse
from fastapi import Request
class TodoLimitError(Exception):
pass
@app.exception_handler(TodoLimitError)
def limit_handler(request: Request, exc: TodoLimitError):
return JSONResponse(status_code=400, content={"error": "todo limit reached"})
Automatic Docs
Every route you’ve written is already documented at /docs (Swagger) and /redoc (ReDoc), generated from your Pydantic models and type hints. Add descriptions with a docstring or Field(description=...) and they show up in the generated schema automatically โ no separate OpenAPI YAML to maintain by hand.
Running in Production
For production, run Uvicorn behind Gunicorn with multiple workers, or use Uvicorn’s own multi-worker mode:
gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
Put it behind Nginx or a managed load balancer for TLS termination, and swap SQLite for PostgreSQL by changing the create_engine connection string โ SQLModel’s API doesn’t change.
If you’re pairing this API with a modern frontend, our Next.js 15 and PostgreSQL guide covers the client side, and for async fundamentals in JavaScript, see our async/await deep dive.
FAQ
Is FastAPI faster than Flask?
FastAPI’s async support and Starlette foundation make it substantially faster under concurrent load, especially for I/O-bound endpoints like database calls or external API requests.
Do I need async def for every route?
No. FastAPI runs synchronous def routes in a thread pool automatically. Use async def when you’re calling async libraries (async DB drivers, httpx) to get real concurrency benefits.
What’s the difference between Pydantic and SQLModel?
Pydantic validates data shapes for requests and responses. SQLModel extends Pydantic models so the same class can also map to a database table, avoiding duplicate schema definitions.
How do I add authentication?
Use FastAPI’s OAuth2PasswordBearer with dependency injection โ a get_current_user dependency validates a JWT and is added to any route that needs protection, keeping auth logic in one place.
Can FastAPI handle WebSockets?
Yes, natively. Define a route with @app.websocket("/ws") and an async def handler that awaits websocket.receive_text() in a loop.
Ready to Build?
Clone the code above into a fresh project, run uvicorn main:app --reload, and start extending the Todo model with fields like due dates or priority. The dependency injection pattern you used for the database session is the same one you’ll reach for with auth, caching, and rate limiting later.
๐ You might also like
๐ Share this article




โ๏ธ Leave a Comment