Selecting the top N rows per group with row_number()

Compute func.row_number().over(partition_by=Order.customer_id, order_by=Order.placed_at.desc()) in a subquery, filter the row number in an outer query, and map the result back with aliased(Order, subquery) — window functions cannot be filtered in the WHERE clause of the query that computes them. This guide belongs to window functions and analytical queries.

Quick Answer

The instinctive version puts the window function in WHERE and fails; the Python version loads everything and slices. One subquery fixes both.

Number inside, filter outside Four steps. The inner select reads orders and computes row_number over a partition by customer_id ordered by placed_at descending, so each customer newest order is 1. That select becomes a subquery, because a window function result cannot be filtered in the same query WHERE clause. The outer select filters rn less than or equal to 3. aliased maps the subquery columns back onto Order objects. inner SELECT row_number() OVER (PARTITION BY customer_id ORDER BY placed_at DESC) .subquery() window results become columns WHERE cannot see them earlier outer SELECT WHERE rn <= 3 three per customer aliased(Order, subq) ORM objects, not tuples Window functions are evaluated after WHERE, which is why the filter needs a second query level.

Before — filtering a window function in place, or slicing in Python:

from sqlalchemy import func, select

from shop.models import Order

rn = func.row_number().over(partition_by=Order.customer_id, order_by=Order.placed_at.desc())
stmt = select(Order).where(rn <= 3)
# ProgrammingError: window functions are not allowed in WHERE


async def latest_three_per_customer_slow(session, customer_ids: list[int]):
    orders = (await session.scalars(
        select(Order).where(Order.customer_id.in_(customer_ids)).order_by(Order.placed_at.desc())
    )).all()
    grouped: dict[int, list[Order]] = {}
    for order in orders:                         # loads every order of every customer
        grouped.setdefault(order.customer_id, [])
        if len(grouped[order.customer_id]) < 3:
            grouped[order.customer_id].append(order)
    return grouped

After — number in a subquery, filter outside, map back to Order:

from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased

from shop.models import Order


async def latest_three_per_customer(
    session: AsyncSession, customer_ids: list[int], n: int = 3
) -> list[Order]:
    numbered = (
        select(
            Order,
            func.row_number()
            .over(
                partition_by=Order.customer_id,
                order_by=(Order.placed_at.desc(), Order.id.desc()),
            )
            .label("rn"),
        )
        .where(Order.customer_id.in_(customer_ids))
        .subquery()
    )
    recent = aliased(Order, numbered)
    stmt = (
        select(recent)
        .where(numbered.c.rn <= n)
        .order_by(recent.customer_id, numbered.c.rn)
    )
    return list(await session.scalars(stmt))

Order.id.desc() in the window ordering is a tiebreaker: two orders placed in the same second would otherwise be numbered in an arbitrary order that can change between runs.

Execution Context & Async Workflow Integration

SQL evaluates a query in a fixed logical order: FROM and joins, then WHERE, then GROUP BY and HAVING, then window functions, then SELECT's output, ORDER BY and LIMIT. Window functions are computed from the rows that survived WHERE, so WHERE cannot refer to their results — they do not exist yet. PostgreSQL says so directly: window functions are not allowed in WHERE. Wrapping the query in a subquery moves the window results into an inner level, where the outer WHERE can filter them like any column.

Ties decide which function you want Three tiles over four orders for one customer where two share the newest timestamp. row_number assigns 1, 2, 3, 4 and keeps exactly two rows, choosing arbitrarily between the tied pair unless a tiebreaker is added. rank assigns 1, 1, 3, 4 and keeps two rows, both tied ones. dense_rank assigns 1, 1, 2, 3 and keeps three rows for a top two. row_number() 1, 2, 3, 4 exactly N rows add id as a tiebreaker rank() 1, 1, 3, 4 ties share a rank may keep fewer distinct ranks dense_rank() 1, 1, 2, 3 no gaps after ties may keep more than N rows Without a unique tiebreaker, row_number picks between ties differently from one run to the next.

over(partition_by=..., order_by=...) maps onto OVER (PARTITION BY ... ORDER BY ...). The partition restarts numbering for each customer; the ordering decides which order is number one. The .label("rn") names the column so the outer query can reach it as numbered.c.rn.

aliased(Order, numbered) tells the ORM that the subquery's columns have the shape of an Order. Selecting recent then produces real Order objects, placed in the identity map like any other loaded row. The extra rn column is present in the subquery but not part of the entity, so it is simply ignored unless you select it alongside: select(recent, numbered.c.rn) returns (Order, int) rows.

Under async, nothing about the construction changes; await session.scalars(stmt) runs it in one round trip. The async-specific concern is what happens next. Relationships on the returned orders are not loaded, and touching order.lines lazily raises MissingGreenlet. Add loader options to the outer select — select(recent).options(selectinload(recent.lines)) — so the lines for exactly the top-N orders are fetched in one additional query. Loader options on an aliased entity use the alias's attributes, not Order's. The broader loading guidance is in using selectinload vs joinedload for N+1 prevention.

The same shape works in Core for reports that need no objects: select columns instead of the entity, and read rows. The parent topic on window functions and analytical queries covers frames and running totals, which use the same over() construct.

Resolving Warnings, Errors & Common Mistakes

Exact error or symptomRoot CauseProduction Fix
window functions are not allowed in WHEREFiltering the row number in the query that computes it.Compute in .subquery() or .cte(), filter outside.
SAWarning: SELECT statement has a cartesian product between FROM element(s)The outer query referenced both Order and the subquery.Use aliased(Order, subq) in the outer query, never Order itself.
Results change between runs for tied timestampsrow_number() ordered by a non-unique column.Add a unique tiebreaker, such as Order.id.desc().
More than N rows per grouprank() or dense_rank() used, which give tied rows equal numbers.Use row_number() for exactly N, or accept ties deliberately.
MissingGreenlet touching relationships on resultsLazy loading on the aliased entities..options(selectinload(recent.lines)) on the outer select.
Slow on customers with long historiesEvery order of every customer is numbered before filtering.LATERAL with LIMIT and a composite index, below.
Window function or LATERAL? Left: the window function version numbers every order of every selected customer before filtering, which is fine when customers have few orders or you need most rows anyway. Right: the lateral version runs a small ORDER BY placed_at DESC LIMIT 3 per customer, which with an index on customer_id and placed_at reads only three index entries per customer, and wins when customers have long histories. row_number() + filter numbers every order, then keeps 3 one pass over the joined rows good: short histories, analytics reads all orders per customer LATERAL (... LIMIT 3) per customer: newest 3 via index needs (customer_id, placed_at) good: long histories, pages of parents reads 3 index entries per customer For N = 1, PostgreSQL DISTINCT ON is shorter than either and uses the same index.

The cartesian-product warning is worth a closer look, because the query it produces is wrong and often huge. Writing select(Order).where(numbered.c.rn <= 3) puts two independent sources in the FROM clause — the orders table and the subquery — with no join condition between them, so every order is paired with every numbered row. SQLAlchemy 1.4 and later warn about exactly this. The fix is to select from the subquery, which is what aliased() expresses, as explained in fixing cartesian product warnings in SQLAlchemy joins.

Choosing among the numbering functions is about ties. row_number() always yields exactly N rows per group, breaking ties arbitrarily unless you give it a tiebreaker. rank() gives tied rows the same number and skips the following numbers, so "top 2" keeps both tied rows and nothing after them. dense_rank() gives tied rows the same number without gaps, so "top 2" can return three or more rows. "The latest three orders" is a row_number() question; "the products in the top two price points" is a dense_rank() question.

Advanced: LATERAL, DISTINCT ON and Row-Limited Relationships

The window-function version numbers every row in each partition before discarding most of them. When groups are small that is irrelevant. When each customer has thousands of orders and you want three, a LATERAL subquery with LIMIT reads only what it needs, provided an index on (customer_id, placed_at) exists.

Rows read for 50 customers × 2,000 orders Bar chart, illustrative. The window function version reads and numbers roughly 100,000 orders. The lateral version with a composite index reads about 150 index entries, three per customer. row_number() over all their orders ≈ 100,000 rows numbered LATERAL ... LIMIT 3 with index ≈ 150 index entries Illustrative. With short histories the two converge, and the window function is often simpler to read.
from sqlalchemy import Index, select, true
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased

from shop.models import Customer, Order

# In the model: Index("ix_orders_customer_id_placed_at", Order.customer_id, Order.placed_at.desc())


async def latest_three_lateral(session: AsyncSession, customer_ids: list[int]):
    latest = (
        select(Order)
        .where(Order.customer_id == Customer.id)
        .order_by(Order.placed_at.desc(), Order.id.desc())
        .limit(3)
        .lateral()
    )
    recent = aliased(Order, latest)
    stmt = (
        select(Customer, recent)
        .join(recent, true())
        .where(Customer.id.in_(customer_ids))
        .order_by(Customer.id, recent.placed_at.desc())
    )
    return (await session.execute(stmt)).all()

For each customer, PostgreSQL runs the inner query as an index scan that stops after three entries. The join condition lives inside the lateral subquery, so the outer join(..., true()) needs no further condition. Use isouter=True to keep customers with no orders.

When N is one, PostgreSQL's DISTINCT ON is shorter still:

from sqlalchemy import select

from shop.models import Order

latest_per_customer = (
    select(Order)
    .distinct(Order.customer_id)
    .order_by(Order.customer_id, Order.placed_at.desc(), Order.id.desc())
)

The ORDER BY must begin with the DISTINCT ON expressions; the rest of the ordering picks which row survives.

Finally, when "the latest three orders" is something the application asks for everywhere, it can be a relationship. A viewonly relationship to an aliased, window-filtered selectable lets selectinload(Customer.recent_orders) load exactly three per customer:

from sqlalchemy import func, select
from sqlalchemy.orm import aliased, relationship

from shop.models import Customer, Order

numbered = select(
    Order,
    func.row_number().over(
        partition_by=Order.customer_id, order_by=(Order.placed_at.desc(), Order.id.desc())
    ).label("rn"),
).subquery()
RecentOrder = aliased(Order, numbered)

Customer.recent_orders = relationship(
    RecentOrder,
    primaryjoin=(Customer.id == RecentOrder.customer_id) & (numbered.c.rn <= 3),
    order_by=RecentOrder.placed_at.desc(),
    viewonly=True,
)

It is assigned after both classes exist because it needs the mapped Order. Being viewonly, it cannot be appended to; writes still go through Customer.orders.

Top N by an Aggregate: Best Sellers per Category

Many top-N questions rank groups by something computed, not by a stored column: the five best-selling products in each category last month, the three customers with the highest spend per region. The pattern gains one step — aggregate first, then number the aggregates — and a CTE keeps each step readable.

Aggregate, rank, filter Three bands. The sales CTE sums units sold per product since a cutoff, grouped by category and product. The ranked CTE applies rank over a partition by category ordered by units descending. The outer select keeps positions up to five, so ties at fifth place are all kept. sales CTE: SUM(quantity) GROUP BY category, product one row per product that sold anything since the cutoff ranked CTE: rank() OVER (PARTITION BY category ORDER BY units DESC) window functions run after GROUP BY, so they can rank the sums outer SELECT: WHERE position <= 5 rank() keeps ties — a category can return six rows when two products tie for fifth
import datetime as dt

from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from shop.models import OrderLine, Product


async def best_sellers_per_category(
    session: AsyncSession, since: dt.date, n: int = 5
) -> list[dict]:
    # 1. Aggregate: units sold per product since the cutoff.
    sales = (
        select(
            Product.category_id,
            Product.id.label("product_id"),
            Product.name,
            func.sum(OrderLine.quantity).label("units"),
        )
        .join(OrderLine, OrderLine.product_id == Product.id)
        .where(OrderLine.created_at >= since)
        .group_by(Product.category_id, Product.id, Product.name)
        .cte("sales")
    )
    # 2. Number the aggregates within each category.
    ranked = (
        select(
            sales,
            func.rank()
            .over(partition_by=sales.c.category_id, order_by=sales.c.units.desc())
            .label("position"),
        )
        .cte("ranked")
    )
    # 3. Keep the top n, ties included.
    stmt = (
        select(ranked)
        .where(ranked.c.position <= n)
        .order_by(ranked.c.category_id, ranked.c.position, ranked.c.name)
    )
    return [dict(row._mapping) for row in await session.execute(stmt)]

rank() is the deliberate choice here. Two products that sold exactly the same number of units are equally "top five", and a merchandising report that silently dropped one of them would be wrong; rank() keeps both and may return six rows for a category. If the consumer needs exactly five — a fixed-size widget — switch to row_number() and add the product name or id as a tiebreaker so the choice is stable.

The window function can also sit directly over the aggregate in a single select, because window functions are evaluated after GROUP BY: func.rank().over(partition_by=Product.category_id, order_by=func.sum(OrderLine.quantity).desc()) is valid in the same query that groups. The outer filter still needs a second level, so the CTE version is no longer, and it is much easier to debug — each CTE can be selected on its own to check its numbers.

For dashboards that run this constantly, the aggregation is the expensive part, not the ranking. A materialised view refreshed on a schedule, or a summary table maintained by the order pipeline, turns the query into a cheap window function over a few thousand pre-aggregated rows. The CTE patterns for insert, update and delete show how to maintain such a summary in the same transaction as the writes that change it.

Frequently Asked Questions

How do I get the latest row per group in SQLAlchemy?

On PostgreSQL, select(Order).distinct(Order.customer_id).order_by(Order.customer_id, Order.placed_at.desc()). Portably, use row_number() in a subquery and filter rn == 1 in the outer query.

Why do I get a cartesian product warning?

Because the outer query selected from both the original table and the subquery. Build an aliased(Order, subquery) and select only from the alias.

Is a window function or LATERAL faster?

LATERAL with LIMIT and a matching composite index reads only N index entries per group, so it wins when groups are large. The window function reads every row in each group, and is simpler and competitive when groups are small.

Can I eager-load relationships on the top-N results?

Yes. Add selectinload() on the aliased entity's attributes to the outer select, so related rows are loaded only for the rows that survived the filter.