kastor0.1.0
Get started

GUIDE 03Avoid N+1 queries

How to avoid N+1 queries in a TypeScript app

The N+1 problem turns one page load into dozens or hundreds of database round trips. This guide shows how to recognize it in your logs and how to avoid N+1 queries with kastor's include, _count and select.

What the N+1 problem looks like

Say you render a list of the latest 50 users, each with their posts. The most natural code is a loop: fetch the users, then fetch posts for each user.

src/routes/authors.ts
// N+1: one query for the list, then one query per user
const users = await db.users.findMany({ take: 50 })

const result = []
for (const user of users) {
  const posts = await db.posts.findMany({
    where: { authorId: user.id, published: true },
  })
  result.push({ ...user, posts })
}

That is 1 query for the users plus 50 queries for the posts: 51 round trips. If each round trip to the database costs 2 ms, the page spends over 100 ms just waiting on the network before the database does any real work. Ask for 500 users and it becomes 501 queries.

The name comes from that count: N queries for the children plus 1 for the parents. The code looks innocent, works with ten rows in development, and gets slower in production in exact proportion to your data.

Where it hides

  • Loops over results that call a helper which queries, such as getAuthor(post.authorId).
  • GraphQL resolvers for a nested field, which run once per parent object.
  • Serializers or view helpers that lazily load a relation for each item.
  • Promise.all(rows.map(...)) with a query inside. It is parallel, but still N queries, and it can drain your connection pool under load.

How to spot N+1 queries

You cannot fix what you cannot see. The fastest check is to log every statement the client runs. Pass log: true when you create the client:

src/db/index.ts
import pg from 'pg'
import { kastor, one, many } from 'kastor-orm'
import { postgres } from 'kastor-orm/postgres'
import { users, posts } from './schema'

export const db = kastor({
  adapter: postgres(new pg.Pool({ connectionString: process.env.DATABASE_URL })),
  schema: { users, posts },
  relations: ({ users, posts }) => ({
    users: { posts: many(posts) },
    posts: { author: one(users, { fields: ['authorId'], references: ['id'] }) },
  }),
  log: true,
})

Now load the slow page once and read the output. The signature of an N+1 is the same statement repeated many times with only a parameter changing. A healthy request usually runs a handful of queries no matter how many rows it shows.

Two more habits help. Enable logging only in development, for example with log: process.env.NODE_ENV !== 'production', so production logs stay clean. Instead of true you can also pass a function, which receives { sql, params, durationMs, error } for every statement, so a test can count queries per request or a dev server can flag slow ones. And when you want to see what a single call will send without running it, remember that kastor queries are lazy: build the query and call .toSQL() on it before awaiting. The playground shows the exact SQL each database receives for the same call.

Load relations with include

The fix for the loop above is to tell the ORM about the relation and ask for it up front. With kastor that is include:

src/routes/authors.ts
const users = await db.users.findMany({
  take: 50,
  orderBy: { createdAt: 'desc' },
  include: {
    posts: {
      where: { published: true },
      select: { id: true, title: true },
      take: 3,
    },
  },
})

// users[0].posts is typed as { id: string; title: string }[] (posts.id is a uuid column)

On PostgreSQL, CockroachDB, MySQL, MariaDB, the SQLite family, SQL Server and Oracle, kastor compiles the whole tree into one query. Each relation is built inside the database with JSON aggregation and returned as a nested value on its parent row, so 50 users with their posts is a single round trip. Includes nest as deep as you need: posts can include their author, comments, or tags, and it is still one query per root call.

The result is fully typed from your schema. If you add select inside the include, the type of posts narrows to exactly those fields, which keeps you honest about what the page actually uses.

MongoDB and the IN list strategy

MongoDB has no SQL, so its adapter does the same job differently: every read is one aggregate call, and each include, with its own where, orderBy, take and nested includes, becomes a $lookup stage inside that call. Relation counts work the same way. Again, one request per root call, not one per document.

The shared SQL compiler also knows a second strategy for databases that cannot aggregate JSON. It loads the parents, collects their keys, and runs one extra query per include level with an IN list of those keys, then stitches the children onto their parents in memory. Fifty users with posts would become two queries, and adding comments under posts would make it three. Every SQL adapter kastor ships today uses JSON aggregation, so you only meet this path in a custom dialect, but the rule holds either way: the count depends on how deep your include is, not on how many rows come back.

Counts, select and limits inside includes

Many N+1 problems are not about loading related rows at all. A list of authors that shows "12 posts" next to each name does not need the posts, only the number. Use _count:

src/routes/authors.ts
const authors = await db.users.findMany({
  where: { posts: { some: { published: true } } },
  select: {
    id: true,
    name: true,
    _count: { select: { posts: true } },
  },
  orderBy: { name: 'asc' },
})

// authors[0]._count.posts is a number

The count is computed in the database and never transfers a single post. The some relation filter in where also runs in the database, so you only get authors who have at least one published post.

Three more rules keep nested loads cheap:

  • Select only what you render. A post body can be many kilobytes. If the list shows titles, select titles. This matters more with includes, because every parent multiplies the payload.
  • Put take and orderBy inside the include. "Latest 3 posts per author" should be expressed as take: 3 on the include, not by loading every post and slicing in JavaScript.
  • Filter children in the include's where. Loading all posts and filtering out drafts in code moves work and bytes from the database to your server for nothing.

Batch lookups that are not modeled relations

Sometimes the data you need is not a relation in your schema: an id stored in a JSON column, a list of ids from a cache, or rows coming from another service that reference your users. You can still avoid the loop. Collect the keys first, load them all with a single in filter, and join in memory with a Map:

src/routes/feed.ts
const events = await fetchRecentEvents() // rows from another service, each with a userId

const userIds = [...new Set(events.map((e) => e.userId))]
const users = await db.users.findMany({
  where: { id: { in: userIds } },
  select: { id: true, name: true },
})

const usersById = new Map(users.map((u) => [u.id, u]))
const feed = events.map((e) => ({ ...e, user: usersById.get(e.userId) ?? null }))

That is two round trips no matter how many events there are. Deduplicate the keys with a Set first, and for very large lists split them into chunks of a few thousand ids, since databases limit how many parameters one statement can carry. This is the same technique kastor uses internally for its include fallback, written by hand for data the schema does not describe.

Paginate the root, not the children

Includes keep the number of queries constant, but the amount of data still grows with the root list. A page that loads every user with their posts is fast with 50 users and slow with 50,000, even in one query. Always bound the root.

For simple pages, take and skip work. For long lists and infinite scroll, keyset pagination is steadier because the database does not have to walk past skipped rows: filter on the last value you returned and order by it.

src/routes/authors.ts
// lastId: id of the last user on the previous page, or null on the first page
const page = await db.users.findMany({
  where: lastId ? { id: { gt: lastId } } : undefined,
  orderBy: { id: 'asc' },
  take: 20,
  include: { _count: { select: { posts: true } } },
})

const nextLastId = page.length === 20 ? page[page.length - 1]!.id : null

kastor also has findPage for cursor pagination with opaque cursors, which saves you from encoding the cursor yourself. Pass the page size as first and the endCursor of the previous page as after (or a startCursor as before to go back). It takes the same where, orderBy, select and include as findMany:

src/routes/authors.ts
// cursor: pageInfo.endCursor from the previous response, or undefined on the first page
const { items, pageInfo } = await db.users.findPage({
  first: 20,
  after: cursor,
  orderBy: { id: 'asc' },
  include: { _count: { select: { posts: true } } },
})

// pageInfo: { startCursor, endCursor, hasNextPage, hasPreviousPage }

See the API reference for the remaining options.

A quick N+1 checklist

Before you ship a list page or an API endpoint that returns collections, run through this:

  1. Turn on log: true locally and load the page with realistic data, not three rows.
  2. Look for repeated statements. Any query inside a loop over results is a candidate.
  3. Replace per row lookups with include, or with one query using an in filter on the collected ids when the data is not a modeled relation.
  4. Use _count when you only need numbers.
  5. Narrow with select, limit children with take, and paginate the root.

For aggregates and reports that do not fit the model API, write the query yourself with db.$sql, which keeps values as parameters. The type-safe SQL guide covers that path, and the Next.js guide shows these patterns inside server components.

Questions people ask

Q1What is the N+1 query problem?

It happens when code runs one query to load a list of N rows, then one more query for each row to load related data. The total is N+1 queries where one or two would do, and the cost grows with the size of the list.

Q2How do I detect N+1 queries?

Turn on query logging with log: true in the kastor client options and load the page. If you see the same statement repeated with only the parameter changing, you have an N+1.

Q3Does kastor prevent N+1 queries?

Relations loaded with include never run one query per row. On every SQL adapter the whole tree loads in one query built with JSON aggregation, and on MongoDB it becomes $lookup stages of one aggregation. A manual loop that queries inside it is still N+1, so use include instead.

Q4Is Promise.all a fix for N+1 queries?

No. Running the per row queries in parallel hides the latency a little, but the database still executes N queries and your pool can run out of connections. Load the relation with include or a single in filter instead.

Q5How do I count related rows without loading them?

Use _count in select or include, for example _count: { select: { posts: true } }. The counts are computed in the database and returned next to each row.

Suggestions

Kas looked under every log. Nothing matches that yet.

Try a method name, a database or a framework.