What is SQLAlchemy?
SQLAlchemy is Python’s most popular database toolkit. It has two big layers:
- SQLAlchemy Core: build SQL expressions and run them (closer to SQL).
- SQLAlchemy ORM: map Python classes to database tables and work with objects.
In SQLAlchemy 2.x, the “2.0 style” API is the recommended way: select(...), Session as a context manager, typed mappings, etc.
1) Install + pick a database
Install
pip install sqlalchemyIf you use Postgres:
pip install psycopg[binary]If you use MySQL:
pip install pymysqlFor this tutorial we’ll use SQLite because it needs no server.
2) Create an engine (the DB connection factory)
from sqlalchemy import create_engine
engine = create_engine("sqlite:///app.db", echo=True)sqlite:///app.dbcreates a file DB namedapp.db.echo=Trueprints SQL to the console (great for learning).
3) Define ORM models (tables as Python classes)
SQLAlchemy 2.x uses typed ORM mappings via Mapped[...] and mapped_column(...).
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from sqlalchemy import String, ForeignKey
from typing import List, Optional
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
# one-to-many: User -> Post
posts: Mapped[List["Post"]] = relationship(back_populates="author")
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
body: Mapped[Optional[str]]
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
author: Mapped["User"] = relationship(back_populates="posts")
Key points:
__tablename__sets the table name.mapped_column(primary_key=True)creates an integer PK by default.ForeignKey("users.id")enforces relational integrity.relationship(...)links objects in Python.
4) Create tables
Base.metadata.create_all(engine)Run that once (or whenever you change models in this simple tutorial). In real projects you’ll use Alembic migrations (covered later).
5) Sessions: the unit of work
A Session is how you talk to the ORM: persist objects, run queries, manage transactions.
from sqlalchemy.orm import Session
with Session(engine) as session:
# work here
session.commit()
Common pattern: “open session → do work → commit”.
6) Insert data
from sqlalchemy.orm import Session
with Session(engine) as session:
u1 = User(email="romaan@example.com", name="Romaan")
u2 = User(email="alex@example.com", name="Alex")
session.add_all([u1, u2])
session.commit()
Insert related objects
with Session(engine) as session:
user = session.query(User).filter_by(email="romaan@example.com").one()
post = Post(title="Hello SQLAlchemy", body="First post", author=user)
session.add(post)
session.commit()
7) Querying with select() (recommended in 2.x)
from sqlalchemy import select
from sqlalchemy.orm import Session
with Session(engine) as session:
stmt = select(User).where(User.email == "romaan@example.com")
user = session.execute(stmt).scalar_one()
print(user.id, user.name)
Get many rows
with Session(engine) as session:
stmt = select(User).order_by(User.id)
users = session.execute(stmt).scalars().all()
for u in users:
print(u.email)
Query posts with join
from sqlalchemy import select
with Session(engine) as session:
stmt = (
select(Post, User)
.join(Post.author)
.where(User.email == "romaan@example.com")
)
rows = session.execute(stmt).all()
for post, user in rows:
print(user.name, "->", post.title)
8) Update data
Update by loading object then editing
from sqlalchemy import select
from sqlalchemy.orm import Session
with Session(engine) as session:
user = session.execute(select(User).where(User.email == "alex@example.com")).scalar_one()
user.name = "Alexandra"
session.commit()
Bulk update (Core-style)
from sqlalchemy import update
from sqlalchemy.orm import Session
with Session(engine) as session:
session.execute(
update(User)
.where(User.email == "alex@example.com")
.values(name="Alexandra")
)
session.commit()
Bulk updates don’t automatically update already-loaded objects in the session unless you refresh them.
9) Delete data
from sqlalchemy import select
from sqlalchemy.orm import Session
with Session(engine) as session:
post = session.execute(select(Post).where(Post.title == "Hello SQLAlchemy")).scalar_one()
session.delete(post)
session.commit()
10) Relationships and loading (the N+1 problem)
If you fetch users and then access user.posts for each user, you might accidentally run many queries.
Eager load with selectinload
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
with Session(engine) as session:
stmt = select(User).options(selectinload(User.posts))
users = session.execute(stmt).scalars().all()
for u in users:
print(u.email, "posts:", [p.title for p in u.posts])
selectinloadusually runs 2 queries total: one for users, one for posts.
11) Transactions + rollback
SQLAlchemy sessions are transactional.
from sqlalchemy.orm import Session
try:
with Session(engine) as session:
session.add(User(email="romaan@example.com", name="Duplicate")) # violates unique
session.commit()
except Exception as e:
print("Failed:", e)
You can also do explicit transaction blocks:
from sqlalchemy.orm import Session
with Session(engine) as session:
with session.begin(): # auto-commit or rollback
session.add(User(email="new@example.com", name="New"))
12) Schema migrations with Alembic (real-world essential)
Install:
pip install alembic
Initialize in your project:
alembic init alembic
Set DB URL in alembic.ini:
sqlalchemy.url = sqlite:///app.db
Configure alembic/env.py to import your Base and set target_metadata = Base.metadata.
Create a migration:
alembic revision --autogenerate -m "create users and posts"
Apply it:
alembic upgrade head
(If you want, tell me your project layout and I’ll show you the exact env.py wiring.)
13) Patterns you’ll use constantly
A) “Repository-ish” helper: get by id
from sqlalchemy import select
from sqlalchemy.orm import Session
def get_user(session: Session, user_id: int) -> User | None:
return session.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
B) Pagination
from sqlalchemy import select
def list_users(session: Session, offset: int = 0, limit: int = 20):
stmt = select(User).order_by(User.id).offset(offset).limit(limit)
return session.execute(stmt).scalars().all()
C) Counting
from sqlalchemy import select, func
with Session(engine) as session:
count = session.execute(select(func.count(User.id))).scalar_one()
print(count)
14) Async SQLAlchemy (quick intro)
If you’re using FastAPI and async endpoints, you’ll likely use AsyncEngine + AsyncSession.
Install an async driver:
- SQLite async:
pip install aiosqlite - Postgres async:
pip install asyncpg
Example:
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
engine = create_async_engine("sqlite+aiosqlite:///app.db", echo=True)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def main():
async with AsyncSessionLocal() as session:
result = await session.execute(select(User))
users = result.scalars().all()
print(users)
asyncio.run(main())
15) Common mistakes (and how to avoid them)
- Forgetting to commit → inserts/updates don’t persist.
- Using ORM objects outside a session (or after session closes) → lazy loads fail.
- Fix: eager load (
selectinload) or keep session scope correct.
- Fix: eager load (
- N+1 queries → load relationships with
selectinload/joinedload. - Not using migrations → production pain. Use Alembic.
A complete runnable mini example
Put this in main.py and run it.
from typing import List, Optional
from sqlalchemy import create_engine, String, ForeignKey, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session, selectinload
engine = create_engine("sqlite:///app.db", echo=True)
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
posts: Mapped[List["Post"]] = relationship(back_populates="author")
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
body: Mapped[Optional[str]]
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
author: Mapped["User"] = relationship(back_populates="posts")
def seed():
Base.metadata.create_all(engine)
with Session(engine) as session:
# Only add if empty
existing = session.execute(select(User)).scalars().first()
if existing:
return
u1 = User(email="romaan@example.com", name="Romaan")
u2 = User(email="alex@example.com", name="Alex")
session.add_all([u1, u2])
session.flush() # get ids without commit
session.add_all([
Post(title="Hello SQLAlchemy", body="First post", author=u1),
Post(title="Second post", body=None, author=u1),
])
session.commit()
def demo_queries():
with Session(engine) as session:
users = session.execute(
select(User).options(selectinload(User.posts)).order_by(User.id)
).scalars().all()
for u in users:
print(u.email, "=>", [p.title for p in u.posts])
if __name__ == "__main__":
seed()
demo_queries()
If you tell me which stack you’re using (SQLite vs Postgres, sync vs async, FastAPI vs Flask vs plain scripts), I’ll tailor this into a “real project” layout (config file, session factory, dependency injection pattern, and Alembic setup).