sethserver.com Subscribe
A cartoon robot standing on an orange ladder reaches toward a stack of colorful blocks labeled with Python framework names (FastAPI, Pydantic, Sydantic, SQLAlchemy) against a light mint green background.

FastAPI + Pydantic + SQLAlchemy: The Modern Python Stack

By Seth Black • Updated: January 26, 2026

Python · 4 min read

Like this kind of writing? Get one email a week with notes on startups, AI, and the occasional strong opinion about Python: subscribe to the newsletter.

I get asked about my stack at least once a month. Usually it's from someone who tried to build their first API and ended up with files scattered everywhere. Here's the payoff: a clean, repeatable layout you can copy without inviting future-you to a fistfight.

This combo shows up in my projects a lot: FastAPI for the API part, Pydantic for the validation bouncer, and SQLAlchemy for the database bits. It starts as "one health endpoint." Then it turns into something that pays salaries.

Here's the structure up front, so you don't have to read this twice:

A layout that won't betray you

app/
  main.py
  db.py
  models.py
  schemas.py
  crud.py
  deps.py
tests/
  test_users.py

One DB session path (because late-night pager duty)

You want one way to get a DB session. Why? Because production o'clock.

Also: SQLite for dev, Postgres for prod (swap DATABASE_URL; the code doesn't care, only your infra does).

db.py

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase

DATABASE_URL = "sqlite:///./app.db"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)

class Base(DeclarativeBase): ...

deps.py

from .db import SessionLocal

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

Why the ceremony? Because the most common failure mode I keep seeing is: session leaks + globals + "it works on my laptop." Under traffic, those "helpful" globals turn into a haunted house. You don't need that.

Models + schemas (store vs. validate)

models.py (SQLAlchemy: what lives in the database)

from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from .db import Base

class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String, unique=True, index=True)

schemas.py (Pydantic: what's allowed in/out)

from pydantic import BaseModel, EmailStr, ConfigDict

class UserCreate(BaseModel):
    email: EmailStr

class UserOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: int
    email: EmailStr

Boring CRUD (the dream)

CRUD is deliberately dull. Functions in, objects out. No magic. No "clever." Clever is fun until you're debugging with one eye open.

crud.py

from sqlalchemy.orm import Session
from . import models, schemas

def create_user(db: Session, user: schemas.UserCreate):
    obj = models.User(email=user.email)
    db.add(obj)
    db.commit()
    db.refresh(obj)
    return obj

def get_user_by_email(db: Session, email: str):
    return db.query(models.User).filter(models.User.email == email).first()

Routes stay thin (they should feel a little boring)

main.py

from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.orm import Session
from .db import Base, engine
from .deps import get_db
from . import crud, schemas

Base.metadata.create_all(bind=engine)
app = FastAPI()

@app.post("/users", response_model=schemas.UserOut)
def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)):
    if crud.get_user_by_email(db, user.email):
        raise HTTPException(400, "Email already registered")
    return crud.create_user(db, user)

A quick "how this saves you" story (and the fix)

I got pulled into a "simple" API that kept failing under load. Not locally. Not in staging. Only when real users showed up, like it was a special guest villain on an old Saturday morning cartoon.

The cause was boring: leaked sessions + global session objects + inconsistent DB access patterns.

The fix was also boring, and that's the point:

After that, debugging stopped feeling like a prank.

Testing (override the DB session once, then move on)

In tests, override the DB session dependency so tests don't share state.

Here's a minimal pytest fixture skeleton that uses an in-memory SQLite database per test session. (You can make it per-test if you want even more isolation.)

tests/conftest.py

import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

from app.main import app
from app.db import Base
from app.deps import get_db

@pytest.fixture
def client():
    engine = create_engine(
        "sqlite+pysqlite:///:memory:",
        connect_args={"check_same_thread": False},
    )
    TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)

    Base.metadata.create_all(bind=engine)

    def override_get_db():
        db = TestingSessionLocal()
        try:
            yield db
        finally:
            db.close()

    app.dependency_overrides[get_db] = override_get_db
    with TestClient(app) as c:
        yield c
    app.dependency_overrides.clear()

tests/test_users.py

def test_create_user(client):
    r = client.post("/users", json={"email": "a@example.com"})
    assert r.status_code == 200
    assert r.json()["email"] == "a@example.com"

If you want to go down the rabbit hole, here are the official spellbooks:

This is the smallest version I've shipped without regretting it. Add Alembic for migrations once your schema starts changing daily. And it will. Usually right after someone says, "We're done with the database."

Sethers

Share this post
Newsletter

One email, once a week.

Notes on databases, systems, and the occasional strong opinion about Python. No spam, unsubscribe anytime.

Seth Black
Written by

Seth Black

Engineer and founder based in Texas. Writes about databases, AI, and running things in production. Embeds in small teams as lead engineer.

More from Python

View all →
Python

OpenAI Bought Astral - and my fav tool uv

Mar 23, 2026
Python

Python Pydantic Validation: Stop Writing Manual Checks

Mar 04, 2026
Python

Python asyncio Recipes

Mar 04, 2026