Databases with an ORM
SQLAlchemy models, sessions, relationships, transactions and the N+1 problem.
Create a free account Try part of it here
Why it matters
The SQL lesson showed how databases store and join data. In application code, writing SQL strings by hand for every query gets repetitive and error-prone. An ORM (object-relational mapper) lets you work with rows as Python objects, and writes the SQL for you. SQLAlchemy is Python's most widely used ORM, and it's what Flask apps and many FastAPI apps use. Knowing what it does underneath is what keeps an app fast and its data correct.
What you'll be able to do
- Define tables as Python classes with SQLAlchemy 2.0 models, Mapped and mapped_column
- Add, query, update and delete rows through a Session, with select() and transactions
- Connect tables with relationships, and spot and fix the N+1 query problem
- Know when plain SQL is clearer than the ORM
What it covers
- Tables as classes
- Reading, changing and deleting
- Relationships
- The N+1 problem
- Average stars per product
- Transactions, and when to write SQL yourself
- Worked example: moving stock in one transaction
Tables as classes
In SQLAlchemy 2.0, each table is a class that inherits from a shared Base. Each column is an attribute with a type hint, Mapped[...], and options from mapped_column:
from sqlalchemy import ForeignKey, String, create_engine, select from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship class Base(DeclarativeBase): pass class Product(Base): __tablename__ = "products" id: Mapped[int] = mapped_column(primary_key=True) sku: Mapped[str] = mapped_column(String(6), unique=True) name: Mapped[str] price: Mapped[float] def __repr__(self): return f"Product({self.sku!r}, {self.price})" engine = create_engine("sqlite://") # an in-memory database; a file would be "sqlite:///shop.db" Base.metadata.create_all(engine) with Session(engine) as session: session.add_all([Product(sku="TE-101", name="Ridgeline 1-person tent", price=200), Product(sku="CA-132", name="Ember spork", price=5)]) session.commit() cheap = session.scalars(select(Product).where(Product.price < 50)).all() print(cheap)
create_all sends the CREATE TABLE statements. Mapped[str] makes a required text column; Mapped[str | None] would allow NULL. The engine knows how to reach the database; a Session is a unit of work: you add objects, query them, change them, and commit() sends all the changes in one transaction.
select(Product).where(Product.price < 50) builds a query from Python expressions: Product.price < 50 isn't compared right away but becomes WHERE products.price < 50. session.scalars(...) runs it and returns objects.
Reading, changing and deleting
from sqlalchemy import String, create_engine, func, select from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column class Base(DeclarativeBase): pass class Product(Base): __tablename__ = "products" id: Mapped[int] = mapped_column(primary_key=True) sku: Mapped[str] = mapped_column(String(6), unique=True) category: Mapped[str] price: Mapped[float] engine = create_engine("sqlite://") Base.metadata.create_all(engine) with Session(engine) as s: s.add_all([Product(sku="TE-101", category="tents", price=200), Product(sku="TE-104", category="tents", price=135), Product(sku="PA-108", category="packs", price=55)]) s.commit() tent = s.scalars(select(Product).where(Product.sku == "TE-104")).one() tent.price = 120 s.commit() by_cat = s.execute(select(Product.category, func.count(), func.avg(Product.price)).group_by(Product.category)).all() print(by_cat) s.delete(s.get(Product, 3)) s.commit() print(s.scalar(select(func.count()).select_from(Product)), "products left")
.one()expects exactly one row and raises otherwise, a useful check;.first()returns the first orNone.- Changing an attribute marks the object as dirty; the next
commit()writes anUPDATE. No save call needed. session.get(Product, 3)fetches by primary key.- Aggregates use
func:func.count(),func.avg(...),func.sum(...), withgroup_by, just like the SQL you know.executereturns rows of plain values instead of objects.
A session is a workspace: it tracks the objects you add or load, and nothing reaches the database until a flush or commit.
from sqlalchemy import create_engine, select from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column class Base(DeclarativeBase): pass class Item(Base): __tablename__ = "items" id: Mapped[int] = mapped_column(primary_key=True) qty: Mapped[int] engine = create_engine("sqlite://") Base.metadata.create_all(engine) with Session(engine) as s: s.add_all([Item(qty=3), Item(qty=0), Item(qty=7)]) s.commit() print(s.scalars(select(Item.qty).where(Item.qty > 0).order_by(Item.qty.desc())).all())
Show the answer
[7, 3]
The where clause drops the 0, and order_by(... .desc()) sorts largest first.
Relationships
A foreign key links rows; a relationship turns that link into Python attributes on both sides:
class User(Base): __tablename__ = "users" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] reviews: Mapped[list["Review"]] = relationship(back_populates="user") class Review(Base): __tablename__ = "reviews" id: Mapped[int] = mapped_column(primary_key=True) stars: Mapped[int] user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) user: Mapped["User"] = relationship(back_populates="reviews")
Now review.user is the User object and user.reviews is a list of their reviews. Appending a new review to user.reviews sets its user_id for you when the session commits. back_populates keeps both sides in step.
By default a relationship is lazy: user.reviews isn't loaded until you first touch it, and then it runs a query. That's convenient, and it's also the source of the most common ORM performance bug. It can also surprise you after the session closes: touching a relationship that was never loaded then raises an error, because there's no session left to run the query.
The N+1 problem
Loop over 100 users and print each one's review count. That's 1 query for the users, plus 1 more each time the loop touches user.reviews: 101 queries, the N+1 problem. It's invisible in a small test and slow in production. Count the queries to see it, then ask for the related rows up front with selectinload:
from sqlalchemy import ForeignKey, create_engine, event, select from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship, selectinload class Base(DeclarativeBase): pass class User(Base): __tablename__ = "users" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] reviews: Mapped[list["Review"]] = relationship(back_populates="user") class Review(Base): __tablename__ = "reviews" id: Mapped[int] = mapped_column(primary_key=True) stars: Mapped[int] user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) user: Mapped[User] = relationship(back_populates="reviews") engine = create_engine("sqlite://") Base.metadata.create_all(engine) queries = [] event.listen(engine, "before_cursor_execute", lambda *args: queries.append(args[2])) with Session(engine) as s: for i in range(20): s.add(User(name=f"member{i}", reviews=[Review(stars=(i % 5) + 1), Review(stars=4)])) s.commit() with Session(engine) as s: queries.clear() counts = [len(u.reviews) for u in s.scalars(select(User))] print("lazy:", len(queries), "queries") with Session(engine) as s: queries.clear() counts = [len(u.reviews) for u in s.scalars(select(User).options(selectinload(User.reviews)))] print("selectinload:", len(queries), "queries")
selectinload(User.reviews) loads every user's reviews in one extra query (WHERE user_id IN (...)), so the total is 2 however many users there are. Counting queries with an event listener, or turning on create_engine(..., echo=True) to print the SQL, is how you catch this before it reaches production.
Average stars per product
The models and data are ready. Set ranking to a list of (sku, average_stars, review_count) tuples for products with at least 2 reviews, best average first, using one select with join, group_by and having. Round the averages to 2.
from sqlalchemy import ForeignKey, create_engine, func, select from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column class Base(DeclarativeBase): pass class Product(Base): __tablename__ = "products" id: Mapped[int] = mapped_column(primary_key=True) sku: Mapped[str] class Review(Base): __tablename__ = "reviews" id: Mapped[int] = mapped_column(primary_key=True) product_id: Mapped[int] = mapped_column(ForeignKey("products.id")) stars: Mapped[int] engine = create_engine("sqlite://") Base.metadata.create_all(engine) with Session(engine) as s: s.add_all([Product(id=1, sku="TE-101"), Product(id=2, sku="PA-108"), Product(id=3, sku="CA-132")]) s.add_all([Review(product_id=p, stars=st) for p, st in [(1, 5), (1, 4), (1, 3), (2, 5), (2, 5), (3, 5)]]) s.commit() ranking = [] print(ranking)
Transactions, and when to write SQL yourself
A session groups changes into a transaction: commit() saves them all, rollback() throws them all away. If anything raises before the commit, nothing is half-saved. with session.begin(): commits at the end of the block, or rolls back if the block raises, which is the safe pattern for a group of changes that must happen together, like moving stock between stores.
The ORM is great for the everyday work of loading objects, following relationships and saving changes. For reports full of aggregates, window functions or database-specific features, plain SQL is often shorter and clearer. SQLAlchemy lets you write it with text(), and still binds parameters safely:
from sqlalchemy import text rows = session.execute(text("SELECT sku, price FROM products WHERE price < :limit ORDER BY price"), {"limit": 50}).all()
Never build SQL by pasting values into the string with an f-string. That's how SQL injection happens, as the security lesson showed. Both select() and text() with :name placeholders send values separately from the query.
In a real app, the schema changes over time. A tool called Alembic records each change as a migration script, so every copy of the database can be upgraded the same way.
Keep sessions short. A web app typically opens one session per request and closes it when the response is sent; a script opens one per unit of work. A session held open for hours keeps stale copies of rows in memory and can hold database locks that block everyone else. Sessions aren't safe to share between threads either: give each thread or request its own, all made from one shared engine. The engine is the long-lived object; it keeps a pool of connections ready, so opening a session is cheap.
Worked example: moving stock in one transaction
Move units between two stores. Either both changes happen, or neither does, even if the second one fails a check.
from sqlalchemy import CheckConstraint, String, create_engine, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column class Base(DeclarativeBase): pass class Stock(Base): __tablename__ = "stock" __table_args__ = (CheckConstraint("qty >= 0", name="no_negative_stock"),) id: Mapped[int] = mapped_column(primary_key=True) store: Mapped[str] = mapped_column(String(20)) sku: Mapped[str] = mapped_column(String(6)) qty: Mapped[int] engine = create_engine("sqlite://") Base.metadata.create_all(engine) with Session(engine) as s: s.add_all([Stock(store="portland", sku="TE-101", qty=5), Stock(store="denver", sku="TE-101", qty=1)]) s.commit() def move(sku, src, dst, qty): with Session(engine) as session, session.begin(): rows = {r.store: r for r in session.scalars(select(Stock).where(Stock.sku == sku))} rows[dst].qty += qty rows[src].qty -= qty def levels(): with Session(engine) as session: return {r.store: r.qty for r in session.scalars(select(Stock))} move("TE-101", "portland", "denver", 2) print("after a good move:", levels()) try: move("TE-101", "denver", "portland", 10) except IntegrityError: print("refused: denver doesn't have 10") print("after the refused move:", levels())
The database itself enforces "no negative stock" with a check constraint. Each operation opens its own short session: with Session(engine) as session, session.begin(): is the standard 2.0 pattern, committing when the block ends and rolling back if it raises. Inside it, the move adds to one store and takes from the other. The second move would leave Denver at -7, so the database refuses it when the changes are sent, and the block rolls back both halves. Portland doesn't keep the 10 units it would have gained. Putting the rule in the database, and the change in a transaction, protects the data even if the Python code has a bug.
Practice what you just read
- 6 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
You change product.price on an object loaded from a session. What writes the change to the database?