Skip to content
Performance7 minstable

Killing an N+1 query under load

The endpoint that's fast in dev and melts in production. Measure the query count, eager-load the relations, replace unbounded subqueries with batched joins, add the covering index, then cache the aggregate.

When to use this

  • An endpoint is fine in development and slow only against production-sized data, with database CPU spiking under load.
  • You suspect per-row queries, correlated subqueries, or a list view that runs a query per item.

Prerequisites

  • Query logging, a debug bar, or an APM that reports queries-per-request.
  • A representative dataset — the bug is invisible on ten rows.
  • The ability to add an index to the relevant table.

Procedure

  1. 1

    Measure — count the queries per request

    Before changing anything, quantify it. Log every query for the endpoint and count them. An N+1 shows up as the same query shape repeated once per row. You want the number, the shape, and the row that triggers it.

    Count and surface the repeated query
    DB::listen(function ($q) {
        logger()->debug('sql', ['q' => $q->sql, 'ms' => $q->time]);
    });
    // Hit the endpoint once; count identical query shapes in the log.
  2. 2

    Eager-load relations to collapse N+1 into constant queries

    The classic N+1 is a loop that lazy-loads a relation per row. Eager-load it up front so the ORM fetches all related rows in one additional query, turning N+1 into 2 — regardless of row count.

    One query for the relation, not one per row
    // Before: 1 + N queries (a query per order for its customer).
    $orders = Order::all();
    foreach ($orders as $o) { $o->customer->name; }
    
    // After: 2 queries total, whatever N is.
    $orders = Order::with('customer')->get();
  3. 3

    Replace unbounded subqueries with a batched join or aggregate

    A correlated `EXISTS`/subquery evaluated per row, or a filter that scans a large child table per parent, is an N+1 in disguise. Rewrite it as a single grouped query that computes all rows at once — filter by group in one pass instead of once per group.

    One grouped pass instead of a scan per parent
    -- Before: correlated subquery re-scanned per conversation.
    -- SELECT ... WHERE EXISTS (SELECT 1 FROM events e
    --   WHERE e.conversation_id = c.id AND e.sentiment = 'negative')
    
    -- After: aggregate once, join the result.
    SELECT c.*
    FROM conversations c
    JOIN (
        SELECT conversation_id
        FROM events
        WHERE sentiment = 'negative'
        GROUP BY conversation_id
    ) neg ON neg.conversation_id = c.id;
  4. 4

    Add the covering index the scoped query needs

    A join or filter is only fast if the column it hits is indexed. Add a composite index whose leading columns match the query's filter — build it `CONCURRENTLY` on a hot table so it doesn't block writes — and confirm the planner uses it.

    Composite index, built without blocking writes
    CREATE INDEX CONCURRENTLY idx_events_conv_sentiment
        ON events (conversation_id, sentiment);
    -- Then confirm: EXPLAIN shows an index scan, not a seq scan.
  5. 5

    Cache the expensive aggregate, tenant-safe with a TTL

    If the aggregate is still costly and tolerates slight staleness, cache it — with per-tenant keys and a TTL backstop, per the cache-invalidation runbook. Cache last, after the query itself is correct and indexed; caching a bad query just hides it.

Verify it worked

  • Query count for the endpoint is constant regardless of the number of rows returned.
  • p95 latency is under target on the representative (production-sized) dataset.
  • `EXPLAIN` shows the new index in use — an index scan, not a sequential scan.

If it goes wrong — rollback

  • Revert the query change if it regresses correctness; the index is additive and safe to leave in place.
  • Drop the cache layer (feature flag) if staleness causes issues — the query is now fast enough to serve uncached.

Want this run for you?

We implement and operate procedures like this in production systems where a misstep is expensive. Fixed scope, fixed price, defined delivery date.

Request a Fixed-Scope Architecture Blueprint