0x74 0x68 0x65 0x72 0x65 0x20 0x61 0x72 0x65 0x20 0x31 0x30 0x20 0x74 0x79 0x70 0x65 0x73 0x20 0x6f 0x66 0x20 0x70 0x65 0x6f 0x70 0x6c 0x65 0x74 0x68 0x6f 0x73 0x65 0x20 0x77 0x68 0x6f 0x20 0x75 0x6e 0x64 0x65 0x72 0x73 0x74 0x61 0x6e 0x64 0x20 0x62 0x69 0x6e 0x61 0x72 0x79 0x20 0x61 0x6e 0x64 0x20 0x74 0x68 0x6f 0x73 0x65 0x20 0x77 0x68 0x6f 0x20 0x64 0x6f 0x6e 0x74
01110100 01101000 01100101 01110010 01100101 00100000 01100001 01110010 01100101 00100000 00110001 00110000 00100000 01110100 01111001 01110000 01100101 01110011 0xdeadbeef 0xcafebabe 0x1337
0x74 0x68 0x6f 0x73 0x65 0x20 0x77 0x68 0x6f 0x20 0x75 0x6e 0x64 0x65 0x72 0x73 0x74 0x61 0x6e 0x64 0x20 0x62 0x69 0x6e 0x61 0x72 0x79 0x20 0x61 0x6e 0x64 0x20 0x74 0x68 0x6f 0x73 0x65 0x20 0x77 0x68 0x6f 0x20 0x64 0x6f 0x6e 0x74 0x74 0x68 0x65 0x72 0x65 0x20 0x61 0x72 0x65 0x20 0x31 0x30 0x20 0x74 0x79 0x70 0x65 0x73 0x20 0x6f 0x66 0x20 0x70 0x65 0x6f 0x70 0x6c 0x65
01010111 01100101 00100000 01101000 01100001 01100011 01101011 00100000 01110100 01101000 01100101 00100000 01110000 01101100 01100001 01101110 01100101 01110100 01100110 00110000 01111000 01100110 00110100 01100100 01100101
0x6e 0x65 0x76 0x65 0x72 0x20 0x67 0x6f 0x6e 0x6e 0x61 0x20 0x64 0x72 0x6f 0x70 0x20 0x79 0x6f 0x75 0x72 0x20 0x73 0x68 0x65 0x6c 0x6c 0x20 0x6e 0x65 0x76 0x65 0x72 0x20 0x67 0x6f 0x6e 0x6e 0x61 0x20 0x6b 0x69 0x6c 0x6c 0x20 0x79 0x6f 0x75 0x72 0x20 0x74 0x68 0x72 0x65 0x61 0x64 0x20 0x6e 0x65 0x76 0x65 0x72 0x20 0x67 0x6f 0x6e 0x6e 0x61 0x20 0x6c 0x6f 0x73 0x65 0x20 0x79 0x6f 0x75 0x72 0x20 0x70 0x61 0x63 0x6b 0x65 0x74 0x20 0x61 0x6e 0x64 0x20 0x64 0x65 0x73 0x65 0x72 0x74 0x20 0x79 0x6f 0x75 0x72 0x20 0x71 0x75 0x65 0x75 0x65
Published on

Todo API Creation with FastAPI

Todo API Creation with FastAPI

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

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:

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.

https://twitter.com/NetPenguins