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-Countheader 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.