Index Ai Academy
Phase 7 · Software engineering · Python in depth

Databases with an ORM

SQLAlchemy models, sessions, relationships, transactions and the N+1 problem.

About 45 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Tables as classes
  2. Reading, changing and deleting
  3. Relationships
  4. The N+1 problem
  5. Average stars per product
  6. Transactions, and when to write SQL yourself
  7. 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)
It runs right here in your browser, with nothing to install.

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 or None.
  • Changing an attribute marks the object as dirty; the next commit() writes an UPDATE. No save call needed.
  • session.get(Product, 3) fetches by primary key.
  • Aggregates use func: func.count(), func.avg(...), func.sum(...), with group_by, just like the SQL you know. execute returns rows of plain values instead of objects.
Try it now

A session is a workspace: it tracks the objects you add or load, and nothing reaches the database until a flush or commit.

Quick check
What does this print?
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())
  1. [7, 3, 0]
  2. [7, 3]
  3. [3, 0, 7]
  4. [3, 7]
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.

Drill

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)
In the lesson, your code is checked as you write it.

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

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())
In the lesson you can change this and run it.

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

Create a free account

Try a quiz question

You change product.price on an object loaded from a session. What writes the change to the database?

  1. session.commit(), which sends an UPDATE for every changed object
  2. Nothing: you must call session.save(product)
  3. It's written the moment you assign
  4. Closing Python

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson