N+1 Effect #

N+1 is one of the performance problems that most often slips into production undetected. There’s no error, no warning message — the code runs correctly and all tests are green. The problem only appears as data grows: an endpoint that used to respond in 50ms suddenly needs 3 seconds, and the database shows hundreds of identical queries per request in the slow query log. The cause is simple: the application runs one query to fetch a list of data, then one more query for every item in that list — producing a total of N+1 queries when 1 or 2 should have been enough. This article covers how N+1 happens, how to detect it in production, three solutions with concrete implementations in Go, and when the N+1 effect is genuinely acceptable to leave alone.

How N+1 Happens: A Step-by-Step Simulation #

To understand the problem, let’s follow the execution flow behind seemingly normal code.

Scenario: An Order List with User Names #

// Code that looks clean and logical
func GetOrderList(ctx context.Context) ([]OrderResponse, error) {
    // Query 1: fetch all orders
    orders, err := db.QueryContext(ctx,
        "SELECT id, user_id, total, status FROM orders LIMIT 20")
    // ...scan rows into []Order

    var result []OrderResponse
    for _, order := range orders {
        // Queries 2 to 21: fetch the user for EACH order
        var user User
        db.QueryRowContext(ctx,
            "SELECT id, name, email FROM users WHERE id = ?",
            order.UserID,
        ).Scan(&user.ID, &user.Name, &user.Email)

        result = append(result, OrderResponse{
            OrderID: order.ID,
            Total:   order.Total,
            Status:  order.Status,
            User:    user,
        })
    }
    return result, nil
}

The queries actually executed against the database:

-- Query 1: fetch the order list
SELECT id, user_id, total, status FROM orders LIMIT 20;

-- Queries 2 to 21: one per order
SELECT id, name, email FROM users WHERE id = 1;
SELECT id, name, email FROM users WHERE id = 5;
SELECT id, name, email FROM users WHERE id = 5;   -- the same user! duplicate
SELECT id, name, email FROM users WHERE id = 12;
SELECT id, name, email FROM users WHERE id = 3;
-- ... 15 more queries

Total: 21 queries to fetch 20 orders. With LIMIT 20 this doesn’t feel too heavy. But when the page grows or the limit gets larger:

Query growth with N+1:
──────────────────────────────────────────────────────────────
  20 orders   → 21 queries   → ~50ms      (feels normal)
  100 orders  → 101 queries  → ~250ms     (starting to feel slow)
  500 orders  → 501 queries  → ~1,200ms   (users complain)
  1,000 orders → 1,001 queries → ~2,500ms (timeouts on some clients)
  Export all → 50,001 queries → minutes   (database choking)
──────────────────────────────────────────────────────────────
  Every query has overhead: network round-trips, parsing,
  query planning, lock checks, result serialization.
  1,000 small queries are far slower than 1 good query.
──────────────────────────────────────────────────────────────

Nested N+1: When the Problem Multiplies #

N+1 becomes far worse when relationships are multi-level. Imagine: orders → order_items → products. Every level multiplies the query count.

// Three-level nested N+1 — very common in unmaintained code
func GetOrdersWithDetails(ctx context.Context) ([]OrderDetail, error) {
    // Query 1: fetch orders
    orders, _ := db.QueryContext(ctx, "SELECT id, user_id FROM orders LIMIT 20")

    for _, order := range orders {  // N = 20 orders
        // Queries 2-21: fetch the user per order
        db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = ?", order.UserID)

        // Queries 22-41: fetch items per order
        items, _ := db.QueryContext(ctx,
            "SELECT id, product_id, qty FROM order_items WHERE order_id = ?", order.ID)

        for _, item := range items {  // M = say 5 items per order
            // Queries 42-141: fetch the product per item
            db.QueryRowContext(ctx,
                "SELECT name, price FROM products WHERE id = ?", item.ProductID)
        }
    }
}

The query count calculation:

Nested N+1 (20 orders, ~5 items per order):
──────────────────────────────────────────────────────────────
  1 query for orders
  + 20 queries for users (1 per order)
  + 20 queries for order_items (1 per order)
  + 100 queries for products (1 per item × 5 items × 20 orders)
  ─────────────────────────────────────────────────────────────
  Total = 141 queries for 20 orders

  With a limit of 100 orders (~5 items each):
  = 1 + 100 + 100 + 500 = 701 queries for 100 orders
──────────────────────────────────────────────────────────────

Three Solutions with Go Implementations #

Solution 1: JOIN — One Query for All Data #

The most efficient solution is combining the needed data into one query with a JOIN. The database optimizer determines the most efficient way to combine them.

// ANTI-PATTERN: N+1 queries
func GetOrdersN1(ctx context.Context) ([]OrderResponse, error) {
    orders, _ := db.QueryContext(ctx, "SELECT id, user_id, total FROM orders LIMIT 20")
    for _, o := range orders {
        db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = ?", o.UserID)
        // N additional queries
    }
    // ...
}

// CORRECT: one query with a JOIN
func GetOrdersWithJoin(ctx context.Context, db *sqlx.DB) ([]OrderResponse, error) {
    type row struct {
        OrderID    int64   `db:"order_id"`
        Total      float64 `db:"total"`
        Status     string  `db:"status"`
        UserID     int64   `db:"user_id"`
        UserName   string  `db:"user_name"`
        UserEmail  string  `db:"user_email"`
    }

    var rows []row
    err := db.SelectContext(ctx, &rows, `
        SELECT
            o.id    AS order_id,
            o.total,
            o.status,
            u.id    AS user_id,
            u.name  AS user_name,
            u.email AS user_email
        FROM orders o
        JOIN users u ON o.user_id = u.id
        ORDER BY o.created_at DESC
        LIMIT 20
    `)
    if err != nil {
        return nil, err
    }

    result := make([]OrderResponse, len(rows))
    for i, r := range rows {
        result[i] = OrderResponse{
            OrderID: r.OrderID,
            Total:   r.Total,
            Status:  r.Status,
            User: UserInfo{
                ID:    r.UserID,
                Name:  r.UserName,
                Email: r.UserEmail,
            },
        }
    }
    return result, nil
}
// Total queries: 1 — regardless of the order count

JOIN fits many-to-one or one-to-one relationships (each order has exactly one user). For one-to-many relationships (one order has many items), JOIN can produce duplicate rows needing deduplication in the app — this is where batch queries fit better.

Solution 2: Batch Queries with IN — Two Queries for All Data #

For one-to-many relationships, the cleaner approach is collecting all needed IDs, fetching them all in one IN query, and doing the mapping in the application layer.

// CORRECT: batch query with IN for one-to-many relationships
func GetOrdersWithItems(ctx context.Context, db *sqlx.DB) ([]OrderWithItems, error) {
    // Query 1: fetch all orders
    var orders []Order
    err := db.SelectContext(ctx, &orders,
        "SELECT id, user_id, total, status FROM orders ORDER BY created_at DESC LIMIT 20")
    if err != nil {
        return nil, err
    }

    if len(orders) == 0 {
        return nil, nil
    }

    // Collect all order IDs
    orderIDs := make([]int64, len(orders))
    for i, o := range orders {
        orderIDs[i] = o.ID
    }

    // Query 2: fetch ALL items for all orders at once
    query, args, err := sqlx.In(
        "SELECT id, order_id, product_id, qty, price FROM order_items WHERE order_id IN (?)",
        orderIDs,
    )
    if err != nil {
        return nil, err
    }

    var items []OrderItem
    err = db.SelectContext(ctx, &items, db.Rebind(query), args...)
    if err != nil {
        return nil, err
    }

    // Mapping in the application layer — O(n), no additional queries
    itemsByOrderID := make(map[int64][]OrderItem)
    for _, item := range items {
        itemsByOrderID[item.OrderID] = append(itemsByOrderID[item.OrderID], item)
    }

    result := make([]OrderWithItems, len(orders))
    for i, order := range orders {
        result[i] = OrderWithItems{
            Order: order,
            Items: itemsByOrderID[order.ID],  // O(1) map lookup
        }
    }
    return result, nil
}
// Total queries: 2 — regardless of the order or item count

This pattern can be extended to three levels at once:

// Three levels at once: orders → items → products
// Only 3 queries total, whatever the data volume
func GetOrdersComplete(ctx context.Context, db *sqlx.DB) ([]OrderComplete, error) {
    // Query 1: orders
    var orders []Order
    db.SelectContext(ctx, &orders, "SELECT ... FROM orders LIMIT 20")

    orderIDs := extractIDs(orders)  // helper to extract an ID slice

    // Query 2: all items for the selected orders
    var items []OrderItem
    query, args, _ := sqlx.In("SELECT ... FROM order_items WHERE order_id IN (?)", orderIDs)
    db.SelectContext(ctx, &items, db.Rebind(query), args...)

    productIDs := extractProductIDs(items)  // collect all product_ids from the items

    // Query 3: all needed products
    var products []Product
    query, args, _ = sqlx.In("SELECT ... FROM products WHERE id IN (?)", productIDs)
    db.SelectContext(ctx, &products, db.Rebind(query), args...)

    // Mapping in the application layer
    productMap := buildProductMap(products)
    itemMap := buildItemMap(items, productMap)

    return buildOrderComplete(orders, itemMap), nil
    // Total: 3 queries — vs 141 queries with N+1
}

Solution 3: Preload via ORM #

If using an ORM like GORM, use Preload to ask the ORM to do batch queries automatically. Don’t use the default lazy loading — it always produces N+1s.

// ANTI-PATTERN: lazy loading in GORM — a hidden N+1
var orders []Order
db.Find(&orders)  // 1 query
for _, order := range orders {
    // Every access to .User triggers a new query if not yet loaded
    fmt.Println(order.User.Name)  // N queries — an N+1!
}

// CORRECT: explicit Preload — GORM does batch queries automatically
var orders []Order
db.Preload("User").Preload("Items").Find(&orders)
// → 1 query for orders
// → 1 query for all users (SELECT ... WHERE id IN (...))
// → 1 query for all items (SELECT ... WHERE order_id IN (...))
// Total: 3 queries, not N+1

// Or for nested relationships:
db.Preload("Items.Product").Find(&orders)
// → 1 orders query
// → 1 items query
// → 1 products query
// Still 3 queries total
ORM Preload doesn’t always produce optimal queries. Always verify the generated SQL by enabling SQL logging in development. GORM provides db.Debug() for this; sqlx requires a manual logging wrapper. Don’t trust that Preload means “already optimal” without looking at the actual queries.

How to Detect N+1 in Production #

An N+1 already in production can be detected several ways, from the most direct to ones requiring extra instrumentation.

Method 1: Per-Request Query Count Logging #

The most effective way is recording the number of queries executed per HTTP request and alerting when it exceeds a threshold.

// Middleware to count queries per request
type QueryCounter struct {
    db    *sql.DB
    count int64
}

func (qc *QueryCounter) reset() { atomic.StoreInt64(&qc.count, 0) }
func (qc *QueryCounter) increment() { atomic.AddInt64(&qc.count, 1) }
func (qc *QueryCounter) get() int64 { return atomic.LoadInt64(&qc.count) }

// Wrapper counting every query
func (qc *QueryCounter) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
    qc.increment()
    return qc.db.QueryContext(ctx, query, args...)
}

// In the HTTP middleware (Fiber/Go):
func QueryCountMiddleware(counter *QueryCounter) fiber.Handler {
    return func(c *fiber.Ctx) error {
        counter.reset()
        err := c.Next()

        count := counter.get()
        if count > 20 {
            // Log a warning — a likely N+1
            log.Warn("high query count",
                "path", c.Path(),
                "method", c.Method(),
                "query_count", count,
            )
        }

        // Include it in a response header for debugging
        c.Set("X-Query-Count", strconv.FormatInt(count, 10))
        return err
    }
}

Method 2: Slow Query Log + Pattern Matching #

The N+1 signature in the slow query log is identical queries (only parameters differ) appearing many times within the same second:

-- In the MySQL slow query log with long_query_time = 0 (log all queries)
-- The N+1 signature is clearly visible:

-- 2026-04-18 10:30:00.123 Query: SELECT name FROM users WHERE id = 1
-- 2026-04-18 10:30:00.124 Query: SELECT name FROM users WHERE id = 5
-- 2026-04-18 10:30:00.125 Query: SELECT name FROM users WHERE id = 5   ← duplicate!
-- 2026-04-18 10:30:00.126 Query: SELECT name FROM users WHERE id = 12
-- ... (50 similar rows within 200ms)

-- Check for duplicate query patterns in MySQL:
-- Enable the general log temporarily:
SET GLOBAL general_log = 'ON';
SET GLOBAL general_log_file = '/var/log/mysql/general.log';
-- Run the suspected endpoint, then disable:
SET GLOBAL general_log = 'OFF';
-- Analyze the log for repeating queries

Method 3: Detection in Development with SQL Logging #

During development, enable logging of all queries and watch for the pattern:

// sqlx with a custom logger
type LoggingDB struct {
    *sqlx.DB
    logger *zap.Logger
}

func (ldb *LoggingDB) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
    start := time.Now()
    rows, err := ldb.DB.QueryContext(ctx, query, args...)
    ldb.logger.Debug("sql query",
        zap.String("query", query),
        zap.Any("args", args),
        zap.Duration("duration", time.Since(start)),
    )
    return rows, err
}

// In tests, you can count queries and assert:
func TestGetOrderList_ShouldNotCauseN1(t *testing.T) {
    counter := &QueryCounter{}
    // ... run GetOrderList
    assert.LessOrEqual(t, counter.get(), int64(3),
        "GetOrderList should not exceed 3 queries (orders + users + items)")
}

Method 4: Query Count Graphs in Monitoring #

If using an APM like Datadog, New Relic, or Prometheus, add a db_query_count_per_request metric per endpoint. Sharp spikes in this metric indicate a new N+1 entering production:

// Prometheus metric
var queriesPerRequest = prometheus.NewHistogramVec(
    prometheus.HistogramOpts{
        Name:    "http_db_queries_per_request",
        Help:    "Number of DB queries executed per HTTP request",
        Buckets: []float64{1, 2, 5, 10, 20, 50, 100, 500},
    },
    []string{"method", "path"},
)

// At the end of the request middleware:
queriesPerRequest.WithLabelValues(method, path).Observe(float64(queryCount))

N+1 Outside the Database Context #

N+1 isn’t only a database problem — the same pattern can happen with external HTTP APIs or internal services:

// N+1 to an external API — just as dangerous
func EnrichOrdersWithShipping(orders []Order) ([]OrderWithShipping, error) {
    var result []OrderWithShipping
    for _, order := range orders {
        // One HTTP call per order — an N+1 to the shipping service!
        shipping, err := shippingService.GetStatus(order.TrackingNumber)
        // ...
    }
    return result, nil
}

// CORRECT: batch call to the external API
func EnrichOrdersWithShippingBatch(orders []Order) ([]OrderWithShipping, error) {
    trackingNumbers := extractTrackingNumbers(orders)

    // One call for all tracking numbers at once
    shippingMap, err := shippingService.GetStatusBatch(trackingNumbers)
    // ...

    for _, order := range orders {
        shipping := shippingMap[order.TrackingNumber]
        // mapping...
    }
}

The principle is the same: don’t do I/O inside a loop if it can be batched.


When N+1 Can Still Be Tolerated #

Not every N+1 needs immediate fixing. There are contexts where N+1 remains acceptable:

N+1 is tolerable if ALL of these conditions hold:
──────────────────────────────────────────────────────────────
  ✓ N is very small and strictly bounded (N ≤ 5)
    → It won't grow over time

  ✓ Not on a hot path / public endpoint
    → Only rarely run admin tools or background jobs

  ✓ Every query is served from cache (Redis/memory)
    → No real database round-trips happen

  ✓ The relationship can't be batched due to business nature
    → E.g. every item needs real-time data from a different source
    → And there's no way to aggregate those requests

N+1 CANNOT be tolerated if:
──────────────────────────────────────────────────────────────
  ✗ N can grow as data grows
  ✗ This endpoint is called frequently (> 10x/minute)
  ✗ Every query hits a real database (not a cache)
  ✗ N > 10, even if the endpoint is rarely called
──────────────────────────────────────────────────────────────

Anti-Patterns to Avoid #

// ✗ Anti-pattern 1: queries inside loops — the classic N+1
for _, order := range orders {
    db.QueryRow("SELECT name FROM users WHERE id = ?", order.UserID)
}
// ✓ Solution: JOIN or batch IN

// ✗ Anti-pattern 2: ORM lazy loading without Preload
db.Find(&orders)
for _, o := range orders {
    fmt.Println(o.User.Name)  // triggers a query per access
}
// ✓ Solution: db.Preload("User").Find(&orders)

// ✗ Anti-pattern 3: "batching" that still queries per item because the loop is outside the batch
for _, orderID := range orderIDs {
    // This is still an N+1 — a batch must be one call for all IDs at once
    db.QueryRow("SELECT * FROM items WHERE order_id = ?", orderID)
}
// ✓ Solution: sqlx.In with all IDs at once

// ✗ Anti-pattern 4: per-item caching but with a high miss rate
for _, order := range orders {
    user, ok := cache.Get(order.UserID)
    if !ok {
        // Cache miss → per-item query → still an N+1 if the miss rate is high
        db.QueryRow("SELECT * FROM users WHERE id = ?", order.UserID)
    }
}
// ✓ Solution for cache misses: batch-fetch all misses at once

// ✗ Anti-pattern 5: N+1 to external HTTP APIs inside loops
for _, item := range items {
    price, _ := pricingAPI.GetPrice(item.ProductID)  // an HTTP call per item!
}
// ✓ Solution: pricingAPI.GetPriceBatch(productIDs)  // one call for all

Code Review Checklist for N+1 #

DURING CODE REVIEW — FLAG IF PRESENT:
  □ Database queries inside for/range loops?
  □ ORM lazy loading accessed without explicit Preload?
  □ HTTP calls to external services inside loops?
  □ Functions accepting a single ID but called in loops
    (there should be a batch version)?

DURING TESTING:
  □ Is there an assertion for the query count per test case?
  □ Is SQL logging enabled and monitored during development?
  □ Is there a test with a larger dataset (>100 items)
    to detect query growth?

IN PRODUCTION:
  □ Is there a query_count_per_request metric per endpoint?
  □ Is there an alert when the query count exceeds a threshold (e.g. > 20)?
  □ Is the slow query log analyzed periodically for duplicate patterns?

Summary #

  • N+1 doesn’t raise errors — it raises bills — the code runs correctly, but for every 100 orders there are 101 queries to the database. The problem is only felt when data grows and traffic rises.
  • The root cause: I/O inside loops — every time there’s a query, HTTP call, or other I/O operation inside an iteration over a list, ask yourself: can this be batched into one operation?
  • Three main solutions: JOIN, batch IN, or Preload — JOIN for many-to-one relationships, batch IN for one-to-many relationships, Preload for ORMs. All produce a constant query count (not growing with N).
  • Nested N+1s multiply the problem — three relationship levels without optimization can produce hundreds of queries for small data. Batch IN fixes at every level bring it back to a constant count.
  • Early detection: per-request query count logging — add an X-Query-Count header in development and alert in production when a threshold is exceeded (e.g. > 20 queries per request).
  • N+1 happens not only to databases — HTTP calls to external APIs inside loops are equally dangerous N+1s. Always look for batch APIs when available.
  • ORM Preload isn’t always optimal — verify the generated SQL by enabling SQL logging. A misconfigured Preload can still produce N+1s or inefficient queries.
  • Small, controlled N+1s can still be tolerated — if N is guaranteed not to grow (always ≤ 5), it’s not a hot path, and every query is served from cache, a small N+1 is sometimes more readable than a complex batch query.

← Previous: Optimistic Locking   Next: Database Roundtrip →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact