Skip to content
Consultation

Blog Card TLDR Without N+1 Queries

Adityo Guni Waluyo

One tiny field on the list page nearly cost a query per article. The fix is one batch IN query: two queries per page, not 1 + N.

The article cards on the list page were supposed to carry a small TL;DR box. Two or three lines above the title. The catch: TL;DR doesn't live in the posts table. It sleeps in a WordPress-style postmeta table, one `meta_key` 'tldr' row with the text in `meta_value`. And the list endpoint had always skipped meta rows on purpose, to stay light. So the API returned clean articles with no TL;DR at all.

The laziest fix: fetch the meta for each post on the page, one query per article. I typed the first line of that loop and stopped. It works, technically. But it's the classic N+1 problem coming through an entrance nobody watches [4].

That Postmeta N+1 Is Harder to Spot

The usual N+1 warning signs aren't here. There's no SQLAlchemy relationship declaration between Post and PostMeta, so no lazy-load warning in your logs [4]. No `N+1` keyword in any error message. The loop just looks like normal Python: fetch one row, read one field, append to a dict. Ten posts, ten queries. Innocent.

The insidious part: this pattern lives entirely in application code. ORM lazy-loading at least announces itself through relationship access patterns. Postmeta-style fetches? Just a plain `session.execute()` inside a `for` loop. Nothing triggers, nothing warns. You won't notice until someone adds pagination to a page with 50 posts and the endpoint suddenly takes 2 seconds.

There's another layer that makes this easy to miss: the postmeta design itself is good for rarely-changing data. Optional fields like TL;DR don't deserve a column on the main table; empty columns are ugly and the schema gets rigid. WordPress has used this separate-metadata pattern for years and nothing is wrong with it. Only the way I was about to fetch it on a list page was wrong.

One Batch Query, Same Flat Cost

The fix is a single query that grabs all needed meta in one shot, maps it back, and keeps the cost at exactly two queries per page regardless of post count:

ids = [row.id for row in rows]
tldr_map: dict[int, str] = {}
if rows:
    meta_res = await session.exec(
        select(ArticlePostmetaTable).where(
            ArticlePostmetaTable.meta_key == "tldr",
            ArticlePostmetaTable.post_id.in_(ids),
        )
    )
    tldr_map = {m.post_id: m.meta_value for m in meta_res.all()}

# stitch back: rows that have a TLDR get their meta attached,
# the ones without pass through as-is
return [
    _to_domain(row, [meta(row.id, "tldr", tldr_map[row.id])])
    if row.id in tldr_map
    else _to_domain(row, [])
    for row in rows
]

``in_()` renders the Python list into a series of bound parameters, `IN (?, ?, ?)`, so there's no string interpolation trickery involved [2]. It's the same idea as SQLAlchemy's `selectinload`, which deliberately loads collections for a whole set of objects with one separate query [5]. The only difference: that one is built in and has a name, while I wrote the manual version because there's no relationship to declare.

The end result: still two queries per page. One for the articles, one for the meta. Not 1 + N. A page holding 20 articles or 50 costs the same. And a page without any TL;DR rows simply skips the attach step, nothing breaks.

Why This Pattern Keeps Slipping Through

Flat cost per page is the key insight. Ten posts or fifty, same two queries. That's what `in_()` buys you: the list of IDs becomes one set of bound parameters, not ten separate round trips [2]. One honest caveat: this runs on a paginated page, so the id list stays reasonable, a dozen or two. Batch-loading millions of ids into `IN` is a different problem, and this isn't the tool for it.

I've made it a personal rule now: the moment a query appears inside a loop in any list endpoint, batch-load it that same day. Don't wait for the scaling problem to show up in production. N+1 from ORM lazy-loading gets all the attention [4], but postmeta-style fetches slip through way more often: no relationship, no warning, no trace. Just a quiet loop doing quiet damage.

Sources

Related reading: semantic related posts, TLDR, and live search, related articles in one parallel fetch, and a homepage that goes live from the API.

Related articles