Aggregates, group_by and labels

SQLAlchemy · Practical · 10 min

Dashboards and reports are aggregations, and doing them in SQL is far faster than loading objects into Python.

SQL functions come from the func namespace: func.count(), func.sum(Order.total), func.avg(...), func.max(...), func.lower(User.name). Any name works (func.date_trunc(...)), rendered as that SQL function, so database-specific functions are available too.

Grouping looks like SQL: select(User.name, func.count(Post.id)).join(User.posts).group_by(User.name). Every selected column must be grouped or aggregated. .having(func.count(Post.id) > 2) filters groups after aggregation, while .where() filters rows before it.

Give computed columns a name with .label("n_posts"). The label becomes the attribute name on result rows (row.n_posts) and can be reused in order_by, either as the label object or by name string.

For single numbers, session.scalar(select(func.count()).select_from(User)) counts rows; note select_from, because func.count() alone doesn't mention a table. Counting a filtered query is select(func.count()).select_from(stmt.subquery()).

Aggregates return Rows, not ORM objects. Combine with an outer join to include zero counts: count a column from the outer side (func.count(Post.id)), because count(*) would count the NULL row as 1.

Example

from sqlalchemy import ForeignKey, create_engine, select, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session

class Base(DeclarativeBase): pass
class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    posts: Mapped[list["Post"]] = relationship()
class Post(Base):
    __tablename__ = "posts"
    id: Mapped[int] = mapped_column(primary_key=True)
    user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))

engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as s:
    s.add_all([User(name="ada", posts=[Post(), Post(), Post()]),
               User(name="bob", posts=[Post()]), User(name="cy")])
    s.commit()
    n = func.count(Post.id).label("n_posts")
    stmt = select(User.name, n).outerjoin(User.posts).group_by(User.name).order_by(n.desc())
    for row in s.execute(stmt):
        print(row.name, row.n_posts)   # ada 3 / bob 1 / cy 0
    print(s.scalar(select(func.count()).select_from(User)))   # 3

Remember

  • func.<name>() renders any SQL function
  • where() filters rows, having() filters groups
  • .label('x') names a result column for row.x and order_by
  • Count a column from the outer-joined side to get zeros

Watch out for

  • Using count(*) with an outer join, which counts 1 for parents with no children.

Check yourself

  1. Which clause filters groups by an aggregate like count > 2?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.sqlalchemy.org/en/20/tutorial/data_select.html#aggregate-functions-with-group-by-having