Systems

The N+1 Problem

It starts as one harmless query, then one more for every row it returns. We will break down how a page that breezed through tests with five records ends up making 201 database round trips under real load.

Rickvian Aldi·Software engineer·May 25, 2026·16 min read

Co-authored with generative AI

N+1 is one of the most common performance bugs in backend code, and one of the easiest to write without noticing. The shape is simple: you run one query to load a list of things, then one more query for each thing in that list to load something related. A page that returns 200 records quietly fires 201 queries. Each query is fast on its own, so nothing looks wrong while you are testing with five rows. Then the list grows in production and the round trips pile up.

It shows up everywhere: REST handlers, GraphQL resolvers, and especially ORM code, where a single ordinary-looking line of Go can hide a query you never see. (An ORM, or object-relational mapper, is a library that maps database rows to structs so you work with objects instead of writing SQL by hand.) Go is not special here, but the way Go engineers structure handlers, resolvers, and ORM calls creates a handful of recurring patterns that make N+1 easy to write and hard to spot.

What N+1 Actually Means

The name is arithmetic. You make 1 query to fetch a list of N items, then N queries to fetch related data for each item. Total round trips to the database: N+1. (A round trip is one query sent to the database plus the response received back — N+1 queries means N+1 of those network hops.)

Another way to think about it: that 1 query to fetch the list is what triggers the N extra queries. One list query fans out into one follow-up query per row it returns.

One list query fans out into N follow-up queries, one per row it returned. The single query at the top is what causes every query below it: 1 query, N children, N+1 total.

For a list of 10 items, that is 11 queries. For a list of 100, it is 101. For a list of 1000, it is 1001. The page that worked fine in development, where you tested with 5 records, starts misbehaving under real traffic because the number of queries scales with the number of rows.

What makes it insidious is that each individual query is fast. The slow query log will not flag a 2ms query. But 200 of them, each requiring a network round trip to the database (roughly 1-2ms on a LAN-connected Postgres instance), adds up to 200-400ms of pure overhead before your handler does anything else.

Throughout the rest of this article we use the same two tables: orders and customers, in a many-to-one relationship.

orders to customers is many-to-one: every order points at one customer through customer_id, so the customer's name lives in a separate table and has to be looked up separately.

An order stores a customer_id, not the customer's name. So any time you want to show an order together with its customer name, you have to go to the customers table to look it up. That gap, the name living in a different table, is exactly where N+1 sneaks in.

N+1 Hides in ORM Code

ORMs are where N+1 bites people first, because the second query does not look like a query. Here is the pattern in GORM, the most common Go ORM.

The models map directly to the two tables:

type Order struct {
    ID         uint
    CustomerID uint
    Amount     float64
    Customer   Customer // the related customer, loaded on demand
}
 
type Customer struct {
    ID   uint
    Name string
}

Now the naive code to load every order with its customer:

var orders []Order
db.Find(&orders) // Query 1: SELECT * FROM orders
 
for i := range orders {
    // One query PER order to fill in the customer.
    // SELECT * FROM customers WHERE id = ?
    db.First(&orders[i].Customer, orders[i].CustomerID)
}

Break it down. The first line, db.Find(&orders), is the single list query. The loop is the problem: db.First(...) runs once for every order, and each call is a separate round trip to the database. Two hundred orders, two hundred extra queries.

The reason this is easy to miss is that db.First(&orders[i].Customer, ...) reads like "just fill in a struct field," not "open a new connection to the database and wait." The query is hidden behind a method call. Some ORMs (ActiveRecord in Rails, Hibernate in Java, GORM v1) make it even more invisible by lazy-loading: a plain field access like order.Customer.Name quietly fires the query for you, so the N+1 is buried in code that looks like it only touches memory. GORM v2 does not lazy-load by default, so the explicit loop above is the form you will typically see in Go code.

The fix is to tell the ORM to load the related rows in one batch up front, usually called eager loading or preloading:

var orders []Order
// Preload collects all the customer_ids and loads every customer
// in ONE extra query, instead of one per order.
db.Preload("Customer").Find(&orders) // 2 queries total, no matter how many orders
 
for i := range orders {
    fmt.Println(orders[i].Customer.Name) // already loaded, no query here
}

Preload turns N+1 into exactly 2 queries: one for the orders, one for all of their customers. Under the hood it does the same thing we are about to write by hand: collect the ids, fetch them in a single IN query, and match them back up.

N+1 in a REST Endpoint Example

To really see what the ORM was doing for us, it helps to write the handler out by hand. We want an endpoint that returns a list of orders, each with its customer's name, using the same two tables from the diagram above.

Here is the whole naive handler. Read it once top to bottom, then we will take it apart.

type Order struct {
    ID           int     `json:"id"`
    CustomerName string  `json:"customer_name"`
    Amount       float64 `json:"amount"`
}
 
func ListOrders(w http.ResponseWriter, r *http.Request) {
    // Query 1: fetch all orders. Runs once.
    rows, err := db.QueryContext(r.Context(), `SELECT id, customer_id, amount FROM orders`)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    defer rows.Close()
 
    var orders []Order
    for rows.Next() {
        var o Order
        var customerID int
        // The orders table only has customer_id, so we scan the id, not the name.
        if err := rows.Scan(&o.ID, &customerID, &o.Amount); err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
 
        // Query N: one customer lookup PER order, inside the loop.
        // 200 orders means 200 of these queries.
        var name string
        err := db.QueryRowContext(r.Context(),
            `SELECT name FROM customers WHERE id = $1`, customerID,
        ).Scan(&name)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        o.CustomerName = name
        orders = append(orders, o)
    }
    if err := rows.Err(); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
 
    json.NewEncoder(w).Encode(orders)
}

Now take it apart in three beats:

  1. The list query. db.QueryContext(...) runs once and returns a rows cursor we step through. This is the "1" in N+1. So far, one query no matter how many orders exist.
  2. The loop. for rows.Next() walks each order. rows.Scan pulls out the order's id and amount, plus a customerID. Notice we get the id, not the name, because the orders table does not store the name.
  3. The per-row lookup. Inside that loop, db.QueryRowContext(...) runs a fresh SELECT ... FROM customers for this one order's customer. This is the "N."

The culprit is the db.QueryRowContext call sitting inside the for rows.Next() loop. The loop body runs once per order, so that customer query runs once per order too. One list query plus one query per row equals N+1. The general tell: any time you see a database call inside a loop over database results, stop and ask whether you can batch it.

There are two ways to fix it.

Fix 1: Use a JOIN

The simplest fix is to not fetch the related data separately at all. A JOIN asks the database to combine the two tables for you in a single round trip.

func ListOrders(w http.ResponseWriter, r *http.Request) {
    rows, err := db.QueryContext(r.Context(), `
        SELECT o.id, c.name, o.amount
        FROM orders o
        JOIN customers c ON c.id = o.customer_id
    `)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    defer rows.Close()
 
    var orders []Order
    for rows.Next() {
        var o Order
        if err := rows.Scan(&o.ID, &o.CustomerName, &o.Amount); err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        orders = append(orders, o)
    }
    if err := rows.Err(); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
 
    json.NewEncoder(w).Encode(orders)
}

The loop no longer queries anything. Two hundred queries become one. This is almost always the right solution when you own the query and the data lives in the same database.

Fix 2: Batch Load with IN

JOINs do not always apply. Maybe the customer data lives in a different service, or your ORM makes the join awkward, or you genuinely need to fetch the orders in a separate step. In those cases, collect all the ids first and make one batched query.

func ListOrders(w http.ResponseWriter, r *http.Request) {
    rows, err := db.QueryContext(r.Context(), `SELECT id, customer_id, amount FROM orders`)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    defer rows.Close()
 
    type orderRow struct {
        id         int
        customerID int
        amount     float64
    }
    var rawOrders []orderRow
    var customerIDs []int
 
    for rows.Next() {
        var o orderRow
        if err := rows.Scan(&o.id, &o.customerID, &o.amount); err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        rawOrders = append(rawOrders, o)
        customerIDs = append(customerIDs, o.customerID)
    }
    if err := rows.Err(); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
 
    // Dedup: multiple orders can share a customer; avoid duplicate placeholders.
    seen := make(map[int]bool)
    var uniqueIDs []int
    for _, id := range customerIDs {
        if !seen[id] {
            seen[id] = true
            uniqueIDs = append(uniqueIDs, id)
        }
    }
 
    // One query for all customers, not one per order.
    customerMap, err := fetchCustomersByIDs(r.Context(), db, uniqueIDs)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
 
    orders := make([]Order, 0, len(rawOrders))
    for _, o := range rawOrders {
        orders = append(orders, Order{
            ID:           o.id,
            CustomerName: customerMap[o.customerID],
            Amount:       o.amount,
        })
    }
 
    json.NewEncoder(w).Encode(orders)
}
 
func fetchCustomersByIDs(ctx context.Context, db *sql.DB, ids []int) (map[int]string, error) {
    if len(ids) == 0 {
        return map[int]string{}, nil
    }
 
    // Build a parameterized IN clause: WHERE id IN ($1, $2, ...).
    placeholders := make([]string, len(ids))
    args := make([]interface{}, len(ids))
    for i, id := range ids {
        placeholders[i] = fmt.Sprintf("$%d", i+1)
        args[i] = id
    }
    query := fmt.Sprintf(
        `SELECT id, name FROM customers WHERE id IN (%s)`,
        strings.Join(placeholders, ","),
    )
 
    rows, err := db.QueryContext(ctx, query, args...)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
 
    result := make(map[int]string)
    for rows.Next() {
        var id int
        var name string
        if err := rows.Scan(&id, &name); err != nil {
            return nil, err
        }
        result[id] = name
    }
    if err := rows.Err(); err != nil {
        return nil, err
    }
    return result, nil
}

Two queries total regardless of how many orders the first query returns. This is the batch loading pattern, and it is the mental model to carry into the GraphQL section, where it shows up again under a different name.

N+1 in a GraphQL Endpoint Example

GraphQL hits N+1 from a different angle: the loop that causes it is never written in your code. To see why, start with the overview, then break down how the server runs it.

The schema exposes the same two tables. An Order has a customer field of type Customer, even though the customer lives in a separate table:

type Order {
  id: ID!
  amount: Float!
  customer: Customer!
}
 
type Customer {
  id: ID!
  name: String!
}
 
type Query {
  orders: [Order!]!
}

A client asks for a list of orders and, for each, the customer's name:

query {
  orders {
    id
    amount
    customer {
      name
    }
  }
}

And here is the naive Go implementation using gqlgen, the most common Go GraphQL library. This is the whole thing:

// In resolver.go
 
func (r *queryResolver) Orders(ctx context.Context) ([]*model.Order, error) {
    // Query 1: fetch all orders.
    return r.DB.GetAllOrders(ctx)
}
 
func (r *orderResolver) Customer(ctx context.Context, obj *model.Order) (*model.Customer, error) {
    // obj is ONE order. obj.CustomerID is just an id.
    // This runs a separate "SELECT ... FROM customers WHERE id = ?".
    return r.DB.GetCustomerByID(ctx, obj.CustomerID)
}

In GraphQL, each field on a type can have its own resolver, a function whose only job is to produce the value for that one field. The server does not run one big SQL statement. It walks the query tree and calls a resolver per field:

  1. It calls the Orders resolver once. That returns N orders.
  2. For each order in that list, it calls the Customer resolver to fill in that order's customer field. If Orders returned 50 orders, Customer runs 50 times.
Orders resolver          -> [order1, order2, ..., orderN]   (1 call)
  Customer resolver(order1) -> customer for order1          (call 1)
  Customer resolver(order2) -> customer for order2          (call 2)
  ...
  Customer resolver(orderN) -> customer for orderN          (call N)

That second step is the loop. It is not a for loop you wrote, it is GraphQL iterating over the list and invoking your field resolver once per element. Each invocation of Customer gets one order, carrying only a customer_id, and must return the full customer. So it goes to the customers table and looks up that one id: one query, once per order. It has no idea the other 49 invocations are looking up customers at the same moment.

Fix: The DataLoader Pattern

The fix is the DataLoader pattern, introduced by Facebook for JavaScript and available in Go through several libraries. A DataLoader collects all the keys that concurrent resolver calls register, then fetches them together in one batched call. It is the batch-with-IN idea from the REST section, wrapped so it works across separate resolver calls.

Instead of each resolver asking "give me customer 42" and immediately querying, it says "I want customer 42, call me back when you have it." The DataLoader coalesces all Load() calls that arrive within the same goroutine scheduling tick, fires a single IN query, and hands each caller its result.

A common Go library is graph-gophers/dataloader. The wiring (building a loader around a batch function and stashing one per request in the context) is boilerplate you can copy from the docs. The part that matters is what the resolver becomes:

func (r *orderResolver) Customer(ctx context.Context, obj *model.Order) (*model.Customer, error) {
    loader := CustomerLoaderFromContext(ctx)
 
    // Load does NOT hit the database. It registers this customer id and
    // returns a thunk — a placeholder function you call later to get the value,
    // once the batch has fired and the result is ready.
    thunk := loader.Load(ctx, obj.CustomerID)
 
    // The batch fires once all concurrent resolvers have registered,
    // then this returns the one customer this order asked for.
    return thunk()
}

CustomerLoaderFromContext retrieves the loader that the middleware stashed in the request context (copy that wiring from the library docs; guard against a missing loader so a forgotten middleware fails loudly instead of panicking on nil).

The resolver barely changes from the naive version. The difference is that loader.Load does not query the database; it registers the key and hands back a thunk. When gqlgen calls all the Customer resolvers for the query, each one registers its customer id. The DataLoader collects them, fires one batched query, and the thunks resolve. The batch function it calls is the same fetchCustomersByIDs with the IN clause from the REST section.

Fifty orders, one database round trip.

Without DataLoader, each order resolver makes its own database call. With DataLoader, all resolver calls in a tick are batched into one query.

Recognizing N+1 Before It Hits Production

Two tools catch N+1 early: a query logger and a test with a query counter.

Query logger during development. Wrap your *sql.DB with a logger that prints every query. You will immediately see the repeated query pattern when you exercise the endpoint. In Go, you can use database/sql's driver hooks or a library like sqlhooks to intercept queries without changing your handler code.

Query counter in tests. For critical endpoints, write a test that asserts on the number of database queries made. If 100 orders should produce exactly 2 queries (one for the list, one for the batch), assert that. Wrap the connection with a counter (sqlhooks again), call the endpoint, and check the count is 2 and not 101. A test like this turns an N+1 regression into a failing build before it reaches production.

The Mental Model Worth Keeping

The N+1 problem is a specific instance of a more general trap: work that grows linearly with input size, hidden inside what looks like a constant-time operation.

A database query inside a loop looks like "get one thing." It is actually "get N things in the slowest possible way." The fix is always the same in structure: collect the keys, batch the fetch, distribute the results. Whether you are in an ORM reaching for Preload, a REST handler building an IN clause, or a GraphQL resolver using a DataLoader, the shape of the solution is identical.

The thing that makes the ORM and GraphQL cases feel harder is that the loop is implicit. You do not see the for rows.Next(). You write a line that fills in one struct, or a function that handles one item, and the framework runs it N times for you.

Once you have seen N+1 once and understood what it is, you will spot the pattern immediately in code reviews: a database call inside a loop, an ORM association loaded per row, a resolver that fetches a single row by id. The fix is rarely the hard part. Recognition is.

The slow endpoint that seems fine in development because you tested with five records is the tell. When you find yourself saying "but the query is so fast," check how many times it runs.

Get essays in your inbox

Practical deep-dives on software craft, career leverage, and building things that matter.