- Published on
Todo API Creation with FastAPI


- Name
- Chad Wilson
- @NetPenguins
Series
Full Stack Todo App
This is part one of a series where we build a full-stack todo app from scratch. By the end you will have a React frontend, a FastAPI backend, a real database, authentication, and the whole thing running in containers. We explain the why behind each decision, not just the what.
This post covers the backend API and database layer.
Before You Start
This series assumes you are comfortable with the basics. If any of the following feel shaky, it is worth spending a few hours on them before diving in. You do not need to be an expert, but the concepts will make a lot more sense if you have seen them before.
Python
- Real Python. Practical, well-written guides. The beginner section is solid. Personal pick
- Python.org Official Tutorial. The official docs are actually good. Work through chapters 1-9.
- Automate the Boring Stuff with Python. Free online. Good for building intuition around real tasks. For book learners
JavaScript and React
- The Odin Project. Free, project-based curriculum. Covers JS fundamentals through React. Personal pick
- javascript.info. The best free resource for modern JavaScript. Read Part 1 at minimum.
- React Docs (react.dev). The official React docs were rewritten recently and are excellent. Start with the Quick Start.
SQL and Databases
- SQLite Tutorial. Covers SQLite specifically, which is what we use here.
- SQLZoo. Interactive SQL practice in the browser. Good for getting the basics down fast.
- Use The Index, Luke. For when you are ready to understand why queries are slow.
Why SQLite to Start
A lot of tutorials start with a full database server. We are not doing that. SQLite is a relational database that lives in a single file on disk. There is nothing to install or configure separately. You point your app at a file and it works.
That makes it great for learning. You can open the database file, look at what is in it, and see exactly what your code is doing. When you are ready for production, switching to PostgreSQL is a one-line config change. We are using SQLAlchemy as the layer between our code and the database, and SQLAlchemy does not care much which database sits under it.
We will flag exactly where and why you would swap SQLite out when you go to production.
Why SQLAlchemy
SQLAlchemy is the most widely used Python ORM. It lets you define your database tables as Python classes and work with them in Python instead of writing raw SQL everywhere. You still get full SQL access when you need it, but the day-to-day operations are clean Python.
We use SQLAlchemy's declarative style with Alembic for migrations. Migrations are how you change your database schema over time without destroying your existing data. Get that habit in early.
Project Structure
todo-api/
├── .env
├── main.py
├── database.py
├── models.py
├── schemas.py
├── routes.py
├── requirements.txt
└── alembic/
├── env.py
└── versions/
Every file earns its place. Let's walk through each one.
Step 1: Virtual Environment
Navigate to your project directory and create a virtual environment:
python -m venv env
Activate it:
# macOS and Linux
source env/bin/activate
# Windows
.\env\Scripts\activate
You will see (env) at the start of your prompt when it is active. Everything you install now stays inside this project and does not bleed into your system Python.
Step 2: Install Dependencies
pip install fastapi uvicorn python-dotenv sqlalchemy alembic pydantic
Save these to a requirements file:
pip freeze > requirements.txt
What each package does:
- fastapi is the web framework
- uvicorn is the ASGI server that runs FastAPI
- python-dotenv loads environment variables from a
.envfile - sqlalchemy is the ORM
- alembic handles database migrations
- pydantic handles data validation
Step 3: Environment Variables
Create a .env file and add it to your .gitignore right now:
DATABASE_URL=sqlite:///./todos.db
The ./ means the database file gets created in whatever directory you run the app from. That single todos.db file is your entire database.
When you switch to PostgreSQL for production, this line becomes:
DATABASE_URL=postgresql://user:password@localhost:5432/tododb
Everything else stays the same.
Step 4: Database Setup
Create database.py:
from os import getenv
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
DATABASE_URL = getenv("DATABASE_URL", "sqlite:///./todos.db")
# SQLite needs this flag to work with FastAPI's threading model.
# Remove connect_args entirely for any other database.
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
engine = create_engine(DATABASE_URL, connect_args=connect_args)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
create_engine connects to your database. The check_same_thread flag is a SQLite quirk. SQLite was built for single-threaded use and complains if multiple threads touch the same connection. FastAPI can handle requests across threads, so we disable that check. Any other database handles this natively and does not need it.
SessionLocal creates database sessions. A session is one unit of work with the database. We create one per request and close it when the request finishes, which is what get_db handles. The yield makes it a generator so FastAPI can use it as a dependency that automatically cleans up.
Base is what all our database models inherit from. SQLAlchemy uses it to track which Python classes map to database tables.
Step 5: A Word on Primary Keys and UUIDs
Before writing the models, let's talk about primary keys.
The default choice is auto-incrementing integers. First row gets 1, next gets 2, and so on. Simple and fast. The problem is they are predictable. If your API hands back a todo with id: 42, anyone looking at that can guess id: 41 and id: 43 also exist. That is an information leak even if the endpoint requires authentication, because it reveals how many records you have and makes them trivial to enumerate.
UUIDs are randomly generated, globally unique, and give nothing away. The tradeoff is they are slightly larger to store and index. For most applications that cost is not meaningful. Build this habit now so you do not have to retrofit it on a production system later.
Step 6: Models
Create models.py:
import uuid
from sqlalchemy import Column, String, Boolean, DateTime, func
from sqlalchemy.dialects.sqlite import TEXT
from database import Base
class Todo(Base):
__tablename__ = "todos"
id = Column(TEXT, primary_key=True, default=lambda: str(uuid.uuid4()))
title = Column(String(200), nullable=False)
description = Column(String(1000), nullable=True)
done = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
Each attribute maps to a column in the database. __tablename__ tells SQLAlchemy what to name the table.
server_default=func.now() lets the database set the timestamp on insert. onupdate=func.now() updates updated_at automatically whenever the row changes. Small details that prevent bugs when you are trying to figure out when something was last modified.
We store the UUID as text because SQLite has no native UUID type. PostgreSQL does, and SQLAlchemy handles that switch automatically.
Step 7: Schemas
Create schemas.py. These are Pydantic models, separate from the SQLAlchemy models. The SQLAlchemy models define what lives in the database. The Pydantic schemas define what comes in and out of the API. Keeping them separate gives you control over what gets exposed.
from pydantic import BaseModel, ConfigDict
from typing import Optional
from datetime import datetime
class TodoCreate(BaseModel):
title: str
description: Optional[str] = None
class TodoUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
done: Optional[bool] = None
class TodoResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
title: str
description: Optional[str] = None
done: bool
created_at: datetime
updated_at: datetime
TodoCreate is what callers send when creating a todo. We do not let them set id, done, or timestamps. Those are controlled by the server.
TodoUpdate has all optional fields since you might only want to change the title or flip the done flag.
TodoResponse is what we send back. The model_config = ConfigDict(from_attributes=True) line tells Pydantic it can read attributes from SQLAlchemy model instances directly. Without it, the conversion from database object to JSON response would fail.
Step 8: Migrations with Alembic
Initialize Alembic in the project root:
alembic init alembic
This creates the alembic/ directory and an alembic.ini config file. Open alembic.ini and find this line:
sqlalchemy.url = driver://user:pass@localhost/dbname
Replace it with:
sqlalchemy.url = sqlite:///./todos.db
Now open alembic/env.py and update it to use our models:
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from database import Base
import models # import so Alembic sees the tables
config = context.config
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline():
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
Generate the initial migration:
alembic revision --autogenerate -m "create todos table"
Alembic looks at your models and generates a migration file in alembic/versions/. Apply it:
alembic upgrade head
Your todos.db file now exists with the todos table in it. Any time you change your models, run alembic revision --autogenerate followed by alembic upgrade head to apply the changes without touching your data.
Step 9: Routes
Create routes.py:
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
import uuid
from database import get_db
from models import Todo
from schemas import TodoCreate, TodoUpdate, TodoResponse
router = APIRouter()
@router.get("/", response_model=List[TodoResponse])
def list_todos(skip: int = 0, limit: int = 50, db: Session = Depends(get_db)):
return db.query(Todo).offset(skip).limit(limit).all()
@router.get("/{todo_id}", response_model=TodoResponse)
def get_todo(todo_id: str, db: Session = Depends(get_db)):
todo = db.query(Todo).filter(Todo.id == todo_id).first()
if not todo:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found")
return todo
@router.post("/", response_model=TodoResponse, status_code=status.HTTP_201_CREATED)
def create_todo(payload: TodoCreate, db: Session = Depends(get_db)):
todo = Todo(
id=str(uuid.uuid4()),
title=payload.title,
description=payload.description,
)
db.add(todo)
db.commit()
db.refresh(todo)
return todo
@router.put("/{todo_id}", response_model=TodoResponse)
def update_todo(todo_id: str, payload: TodoUpdate, db: Session = Depends(get_db)):
todo = db.query(Todo).filter(Todo.id == todo_id).first()
if not todo:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found")
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(todo, field, value)
db.commit()
db.refresh(todo)
return todo
@router.delete("/{todo_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_todo(todo_id: str, db: Session = Depends(get_db)):
todo = db.query(Todo).filter(Todo.id == todo_id).first()
if not todo:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found")
db.delete(todo)
db.commit()
Depends(get_db) is FastAPI's dependency injection. Any route that declares it gets a fresh database session automatically. FastAPI opens it, passes it in, and closes it when the request is done.
payload.model_dump(exclude_unset=True) on the update route returns only the fields that were actually sent in the request body. If you only send {"done": true}, only done gets updated. Without exclude_unset=True you would overwrite all other fields with their defaults on every partial update.
db.refresh(todo) after a commit reloads the object from the database so any server-generated values like timestamps show up correctly in the response.
The list endpoint has skip and limit for basic pagination so the API never dumps thousands of records in one shot.
Step 10: Main Application
Create main.py:
from os import getenv
from dotenv import load_dotenv
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
load_dotenv()
from database import engine, Base
from routes import router as todo_router
Base.metadata.create_all(bind=engine)
app = FastAPI(title="Todo API", version="1.0.0")
app.include_router(todo_router, prefix="/todos", tags=["todos"])
# Scope CORS to your actual frontend origin.
# "*" allows any site to call your API. Dangerous with credentials.
# Replace this with your real frontend URL in production.
origins = [
"http://localhost:3000",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["*"],
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = [{"field": e["loc"][-1], "message": e["msg"]} for e in exc.errors()]
return JSONResponse(status_code=422, content={"detail": "Validation error", "errors": errors})
@app.get("/")
async def root():
return {"message": "Todo API is running"}
Base.metadata.create_all(bind=engine) creates any missing tables on startup. This is fine for development. In production, remove it and rely on Alembic migrations. create_all will not update existing tables when your schema changes. Alembic will.
load_dotenv() is at the top so your .env values are loaded before anything else imports from them.
Running the App
uvicorn main:app --reload
Open http://localhost:8000/docs for the interactive Swagger UI. FastAPI generates this from your code automatically. Try creating, reading, updating, and deleting todos right in the browser before moving on.
You should also see todos.db appear in your project directory. DB Browser for SQLite is a free tool that lets you open the file and inspect the data directly.
What Comes Next
We have a working REST API backed by a real database with proper migrations. Swapping SQLite for PostgreSQL in production is genuinely just a one-line config change.
What this API does not have is authentication. Right now anyone who can reach port 8000 can create, modify, or delete any todo. We will fix that later in the series when we walk through FastAPI's built-in OAuth2 support.
First though, the React frontend.
Hope you are enjoying the series! Follow on Twitter for updates when new posts drop.