FastAPI SQLAlchemy CRUD with SQLite
This setup creates a complete small FastAPI backend slice. It uses Pydantic schemas for request and response contracts, SQLAlchemy models for database tables, SQLite for local persistence, and CRUD routes for users and posts.
Project structure
Section titled “Project structure”Create a separate project folder for the SQLAlchemy CRUD example. This example builds on the Docker setup pattern but adds a database layer, schemas, models, and route handlers.
02-sqlalchemy-crud/ app/ __init__.py database.py main.py models.py schemas.py requirements.txt Dockerfile compose.yamlmkdir 02-sqlalchemy-crudcd 02-sqlalchemy-crudmkdir apptouch app/__init__.pyCreate the database connection
Section titled “Create the database connection”Create the database setup file at app/database.py. This file defines the SQLAlchemy engine, session factory, Base class, and FastAPI dependency used by routes.
app/database.pyimport os
from sqlalchemy import create_enginefrom sqlalchemy.orm import declarative_base, sessionmaker
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:////data/app.db")
connect_args = {}
if DATABASE_URL.startswith("sqlite"): connect_args = { "check_same_thread": False }
engine = create_engine( DATABASE_URL, connect_args=connect_args,)
SessionLocal = sessionmaker( autocommit=False, autoflush=False, bind=engine,)
Base = declarative_base()
def get_db(): db = SessionLocal() try: yield db finally: db.close()The SessionLocal object creates database sessions. The get_db dependency opens one session for the request and closes it after the route finishes.
Create the SQLAlchemy models
Section titled “Create the SQLAlchemy models”Create the database models file at app/models.py. User and Post are SQLAlchemy models that map to database tables. The relationship allows a user to own many posts.
app/models.pyfrom datetime import UTC, datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Textfrom sqlalchemy.orm import relationship
from app.database import Base
class User(Base): __tablename__ = "users"
id = Column(Integer, primary_key=True, index=True) username = Column(String(50), unique=True, nullable=False, index=True) email = Column(String(120), unique=True, nullable=False, index=True)
posts = relationship( "Post", back_populates="author", cascade="all, delete-orphan", )
class Post(Base): __tablename__ = "posts"
id = Column(Integer, primary_key=True, index=True) title = Column(String(100), nullable=False) content = Column(Text, nullable=False) user_id = Column( Integer, ForeignKey("users.id"), nullable=False, index=True, ) date_posted = Column( DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False, )
author = relationship( "User", back_populates="posts", )The cascade setting means that deleting a user also deletes that user’s posts. This keeps the user-post relationship consistent.
Create the Pydantic schemas
Section titled “Create the Pydantic schemas”Create the schema file at app/schemas.py. These Pydantic models define request bodies, update bodies, and response shapes.
app/schemas.pyfrom datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class UserBase(BaseModel): username: str = Field(min_length=1, max_length=50) email: EmailStr = Field(max_length=120)
class UserCreate(UserBase): pass
class UserUpdate(BaseModel): username: str | None = Field(default=None, min_length=1, max_length=50) email: EmailStr | None = Field(default=None, max_length=120)
class UserResponse(UserBase): model_config = ConfigDict(from_attributes=True)
id: int
class PostBase(BaseModel): title: str = Field(min_length=1, max_length=100) content: str = Field(min_length=1)
class PostCreate(PostBase): pass
class PostUpdate(BaseModel): title: str | None = Field(default=None, min_length=1, max_length=100) content: str | None = Field(default=None, min_length=1)
class PostResponse(PostBase): model_config = ConfigDict(from_attributes=True)
id: int user_id: int date_posted: datetime
class UserWithPosts(UserResponse): posts: list[PostResponse] = []UserCreate and PostCreate describe incoming request bodies. UserResponse, PostResponse, and UserWithPosts control what the API sends back.
Create the FastAPI app
Section titled “Create the FastAPI app”Create the main application file at app/main.py. This file creates the database tables on startup and defines the user and post CRUD routes.
app/main.pyfrom contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, HTTPException, Response, statusfrom sqlalchemy import or_, selectfrom sqlalchemy.orm import Session, selectinload
from app import modelsfrom app.database import Base, engine, get_dbfrom app.schemas import ( PostCreate, PostResponse, PostUpdate, UserCreate, UserResponse, UserUpdate, UserWithPosts,)
@asynccontextmanagerasync def lifespan(_app): Base.metadata.create_all(bind=engine) yield
app = FastAPI( title="02 SQLAlchemy CRUD", lifespan=lifespan,)
@app.get("/")def home(): return { "message": "FastAPI SQLAlchemy CRUD example" }
@app.post( "/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED,)def create_user( user: UserCreate, db: Session = Depends(get_db),): existing_user = db.execute( select(models.User).where( or_( models.User.username == user.username, models.User.email == user.email, ) ) ).scalar_one_or_none()
if existing_user: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Username or email already exists", )
db_user = models.User( username=user.username, email=user.email, )
db.add(db_user) db.commit() db.refresh(db_user)
return db_user
@app.get("/users", response_model=list[UserResponse])def list_users(db: Session = Depends(get_db)): users = db.execute( select(models.User).order_by(models.User.id) ).scalars().all()
return users
@app.get("/users/{user_id}", response_model=UserWithPosts)def get_user( user_id: int, db: Session = Depends(get_db),): user = db.execute( select(models.User) .options(selectinload(models.User.posts)) .where(models.User.id == user_id) ).scalar_one_or_none()
if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found", )
return user
@app.patch("/users/{user_id}", response_model=UserResponse)def update_user( user_id: int, payload: UserUpdate, db: Session = Depends(get_db),): user = db.get(models.User, user_id)
if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found", )
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items(): setattr(user, field, value)
db.commit() db.refresh(user)
return user
@app.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)def delete_user( user_id: int, db: Session = Depends(get_db),): user = db.get(models.User, user_id)
if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found", )
db.delete(user) db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@app.post( "/users/{user_id}/posts", response_model=PostResponse, status_code=status.HTTP_201_CREATED,)def create_post( user_id: int, post: PostCreate, db: Session = Depends(get_db),): user = db.get(models.User, user_id)
if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found", )
db_post = models.Post( title=post.title, content=post.content, user_id=user.id, )
db.add(db_post) db.commit() db.refresh(db_post)
return db_post
@app.get("/posts", response_model=list[PostResponse])def list_posts(db: Session = Depends(get_db)): posts = db.execute( select(models.Post).order_by(models.Post.id) ).scalars().all()
return posts
@app.get("/posts/{post_id}", response_model=PostResponse)def get_post( post_id: int, db: Session = Depends(get_db),): post = db.get(models.Post, post_id)
if not post: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Post not found", )
return post
@app.patch("/posts/{post_id}", response_model=PostResponse)def update_post( post_id: int, payload: PostUpdate, db: Session = Depends(get_db),): post = db.get(models.Post, post_id)
if not post: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Post not found", )
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items(): setattr(post, field, value)
db.commit() db.refresh(post)
return post
@app.delete("/posts/{post_id}", status_code=status.HTTP_204_NO_CONTENT)def delete_post( post_id: int, db: Session = Depends(get_db),): post = db.get(models.Post, post_id)
if not post: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Post not found", )
db.delete(post) db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)Add dependencies
Section titled “Add dependencies”Create the dependency file at requirements.txt. SQLAlchemy handles the database layer and email-validator supports EmailStr in the Pydantic schema.
requirements.txtfastapiuvicorn[standard]sqlalchemyemail-validatorCreate the Dockerfile
Section titled “Create the Dockerfile”Create the Dockerfile at the project root. The container owns the Python environment and runs the FastAPI app with Uvicorn.
DockerfileFROM python:3.12-slim
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"ENV PYTHONDONTWRITEBYTECODE=1ENV PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --upgrade pipRUN pip install -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]Create the Compose file
Section titled “Create the Compose file”Create the Docker Compose file at compose.yaml. The app code is mounted into the container and the SQLite database file is stored in a named Docker volume.
compose.yamlservices: backend: build: . container_name: fastapi-02-sqlalchemy-crud ports: - "8000:8000" environment: DATABASE_URL: sqlite:////data/app.db volumes: - .:/app - sqlite_data:/data
volumes: sqlite_data:Run the project
Section titled “Run the project”Run Docker Compose from inside the project folder. The app creates the SQLite database tables during FastAPI startup.
docker compose up --buildOpen the API docs page to test the routes from the browser.
http://localhost:8000/docsCreate and read users
Section titled “Create and read users”Create a user through POST /users. The request body is validated by UserCreate and the response is shaped by UserResponse.
{ "username": "baris",}{ "username": "baris", "id": 1}List users with GET /users or open a single user with GET /users/1.
http://localhost:8000/usershttp://localhost:8000/users/1Create and read posts
Section titled “Create and read posts”Create a post for an existing user through POST /users/1/posts. The user_id comes from the URL and is assigned by the backend.
{ "title": "First FastAPI Post", "content": "This post belongs to user 1."}List posts with GET /posts or open a single post with GET /posts/1.
http://localhost:8000/postshttp://localhost:8000/posts/1Read relationship data
Section titled “Read relationship data”Open GET /users/1 after creating posts. The UserWithPosts response model returns the user with nested posts.
http://localhost:8000/users/1{ "username": "baris", "id": 1, "posts": [ { "title": "First FastAPI Post", "content": "This post belongs to user 1.", "id": 1, "user_id": 1, "date_posted": "2026-01-01T12:00:00" } ]}Update and delete records
Section titled “Update and delete records”Use PATCH routes for partial updates. The schema only applies fields that are sent in the request body.
PATCH /users/1PATCH /posts/1{ "username": "updated-baris"}Use DELETE routes to remove records. Deleting a user also deletes that user’s posts because the relationship uses delete-orphan cascade.
DELETE /posts/1DELETE /users/1Mental model
Section titled “Mental model”This example separates API contracts from database models. Pydantic schemas describe what enters and leaves the API. SQLAlchemy models describe how data is stored.
UserCreate incoming request body
UserResponse outgoing response body
User SQLAlchemy database model
Session database unit of work for one request
Route function validates input uses database session returns response modelStop the project
Section titled “Stop the project”Stop the running container from the active Docker Compose terminal or shut it down from another terminal.
# Press this in the running terminalCTRL + C
# Or run this from another terminaldocker compose down