N+1 Query #

N+1 query problem adalah salah satu masalah performa paling umum di web application yang menggunakan database relasional, dan juga salah satu yang paling sering tidak terdeteksi sampai terlambat. Kodenya terlihat bersih, unit test hijau, dan di development environment dengan data sedikit semuanya cepat. Tapi saat data bertambah dan traffic naik di production, halaman yang tadinya terasa instan mulai terasa lambat — dan saat di-debug, ternyata satu API request menghasilkan ratusan query ke database. Artikel ini membahas mengapa N+1 terjadi, bagaimana mendeteksinya, dan empat pendekatan solusi yang berbeda untuk situasi yang berbeda.

Apa Itu N+1 Query? #

N+1 query terjadi ketika aplikasi menjalankan satu query utama untuk mengambil N item, lalu menjalankan satu query tambahan per item untuk mengambil data yang berelasi — menghasilkan total N+1 query untuk yang seharusnya bisa diselesaikan dengan 1 atau 2 query.

sequenceDiagram
    participant App as Application
    participant DB as Database

    Note over App,DB: N+1 Query — untuk 100 artikel

    App->>DB: SELECT * FROM articles LIMIT 100
    DB-->>App: 100 artikel

    loop Untuk setiap dari 100 artikel
        App->>DB: SELECT * FROM users WHERE id = ?
        DB-->>App: 1 user
    end

    Note over App,DB: Total: 1 + 100 = 101 query

    Note over App,DB: Yang seharusnya terjadi: 2 query saja

    App->>DB: SELECT * FROM articles LIMIT 100
    DB-->>App: 100 artikel

    App->>DB: SELECT * FROM users WHERE id IN (1,2,3,...,50)
    DB-->>App: semua user sekaligus

Angka ini mungkin terlihat tidak terlalu besar untuk 100 item. Tapi masalahnya bersifat linear: 1000 item = 1001 query, 10.000 item = 10.001 query. Dan setiap query memiliki overhead: network round-trip ke database, parsing SQL, eksekusi, pengiriman result kembali.


Mengapa N+1 Terjadi #

ORM Lazy Loading yang Tidak Terlihat #

Sebagian besar ORM — GORM, ActiveRecord, Hibernate, SQLAlchemy, Sequelize — menggunakan lazy loading secara default atau mudah jatuh ke pola N+1. Kodenya terlihat bersih dan sederhana, tapi menyembunyikan behavior yang mahal.

// Contoh GORM yang menghasilkan N+1
articles, _ := db.Find(&[]Article{})
for _, article := range articles {
    fmt.Println(article.Author.Name)  // ← setiap akses .Author trigger query baru!
}

// Log SQL yang tersembunyi di balik kode yang "bersih" ini:
// SELECT * FROM articles;
// SELECT * FROM users WHERE id = 1;
// SELECT * FROM users WHERE id = 5;
// SELECT * FROM users WHERE id = 5;  ← ID yang sama bisa di-query ulang!
// SELECT * FROM users WHERE id = 2;
// ... 100 query lebih
# Contoh Django ORM yang sama
articles = Article.objects.all()
for article in articles:
    print(article.author.name)  # ← implicit database query per artikel
# Hasil: 1 + N query

Tidak Terlihat di Development #

N+1 hampir tidak terdeteksi di development environment karena:

Development vs Production — mengapa N+1 tersembunyi:

Development:
  Data: 10–50 baris di setiap tabel
  N+1 di 10 artikel = 11 query → response time: ~30ms → "cepat kok"
  Developer tidak lihat SQL log secara aktif

Production:
  Data: 50.000 baris di tabel articles
  N+1 dengan pagination 50 item = 51 query
  Tapi setiap query butuh index scan di tabel besar...
  → response time: 2–5 detik → user complaint

Jebakan: N+1 adalah masalah yang O(n) terhadap jumlah data,
          bukan O(n) terhadap jumlah request.

Dampak Nyata di Production #

Sebelum membahas solusi, penting untuk memahami dampak kuantitatif dari N+1.

Kalkulasi dampak N+1:

Halaman daftar produk dengan 50 item per halaman:
  Dengan N+1: 1 query (products) + 50 query (category) + 50 query (brand) = 101 query
  Tanpa N+1:  1 query (products + JOIN category + brand) = 1 query

Jika satu query = 2ms (estimasi konservatif):
  Dengan N+1: 101 × 2ms = 202ms hanya untuk database
  Tanpa N+1:  1 × 5ms = 5ms (JOIN lebih kompleks tapi masih jauh lebih cepat)

Jika ada 100 concurrent user pada halaman yang sama:
  Dengan N+1: 100 × 101 = 10.100 query ke database per detik
  Tanpa N+1:  100 × 1 = 100 query ke database per detik

Database connection pool (misalnya limit 100):
  Dengan N+1: 100 concurrent user × 101 query = pool habis → queue → timeout
  Tanpa N+1:  100 concurrent user × 1 query = pool masih aman

Cara Mendeteksi N+1 #

SQL Logging di Development #

Aktifkan SQL logging dan perhatikan polanya saat mengembangkan fitur baru. Tanda-tanda N+1 yang jelas: query yang sama (dengan parameter berbeda) muncul berulang kali.

// Aktifkan SQL logging di GORM
db, _ := gorm.Open(postgres.Open(dsn), &gorm.Config{
    Logger: logger.Default.LogMode(logger.Info),
})

// Output yang menunjukkan N+1:
// [2.031ms] SELECT * FROM "articles" ORDER BY created_at DESC LIMIT 50
// [0.823ms] SELECT * FROM "users" WHERE "users"."id" = 5
// [0.791ms] SELECT * FROM "users" WHERE "users"."id" = 12
// [0.808ms] SELECT * FROM "users" WHERE "users"."id" = 5  ← DUPLIKAT!
// [0.815ms] SELECT * FROM "users" WHERE "users"."id" = 7
// ... 46 query lagi
// Total: 51 queries untuk menampilkan 50 artikel

Menghitung Total Query per Request #

Cara sederhana untuk deteksi N+1 secara programatik:

// Middleware untuk menghitung query per request
type QueryCounter struct {
    db    *gorm.DB
    count int
}

func (qc *QueryCounter) Middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        qc.count = 0
        // Register callback untuk menghitung query
        qc.db.Callback().Query().After("gorm:query").Register("count_queries",
            func(db *gorm.DB) { qc.count++ })

        next.ServeHTTP(w, r)

        if qc.count > 10 {  // threshold yang kamu tentukan
            log.Warnf("High query count: %d queries for %s %s",
                qc.count, r.Method, r.URL.Path)
        }
    })
}

Gunakan APM atau Query Profiler #

Untuk production, tools seperti Datadog APM, New Relic, atau pgBadger (untuk PostgreSQL) bisa menampilkan distribusi jumlah query per request endpoint, memudahkan identifikasi endpoint yang memiliki N+1.

PostgreSQL memiliki pg_stat_statements extension yang mencatat statistics semua query yang dieksekusi, termasuk berapa kali query yang sama dieksekusi. Ini sangat berguna untuk mendeteksi N+1 di production: query SELECT * FROM users WHERE id = $1 yang dieksekusi 10.000 kali dalam satu menit adalah red flag yang jelas.

Empat Solusi N+1 Query #

Solusi 1: Eager Loading dengan JOIN #

Solusi paling langsung: ambil data parent dan relasinya dalam satu query menggunakan JOIN.

-- ANTI-PATTERN: N+1
SELECT * FROM articles LIMIT 50;
SELECT * FROM users WHERE id = ?;  -- ×50

-- BENAR: JOIN dalam satu query
SELECT
    a.id, a.title, a.content, a.published_at,
    u.id AS author_id, u.name AS author_name, u.avatar_url
FROM articles a
JOIN users u ON u.id = a.author_id
ORDER BY a.published_at DESC
LIMIT 50;
-- 1 query, semua data tersedia
// GORM dengan eager loading
var articles []Article
db.Preload("Author").
   Preload("Tags").
   Preload("Category").
   Find(&articles)
// GORM menggunakan strategi yang efisien:
// 1 query untuk articles
// 1 query IN untuk semua author yang dibutuhkan
// 1 query IN untuk semua tag
// Total: 3-4 query, bukan 1 + 3N query

Kapan menggunakan JOIN vs Preload:

  • Gunakan JOIN jika butuh filter berdasarkan data relasi (WHERE author.role = 'admin')
  • Gunakan Preload jika hanya butuh relasi untuk ditampilkan tanpa filter

Solusi 2: Batch Query dengan IN Clause #

Jika tidak menggunakan ORM atau butuh kontrol lebih, implementasikan batching sendiri.

// ANTI-PATTERN: Query per item
func GetArticlesWithAuthors(articleIDs []string) ([]ArticleWithAuthor, error) {
    var result []ArticleWithAuthor
    for _, id := range articleIDs {
        var article Article
        db.Where("id = ?", id).First(&article)

        var author User
        db.Where("id = ?", article.AuthorID).First(&author)  // ← N+1

        result = append(result, ArticleWithAuthor{Article: article, Author: author})
    }
    return result, nil
}

// BENAR: Batch query dengan IN
func GetArticlesWithAuthors(articleIDs []string) ([]ArticleWithAuthor, error) {
    // Ambil semua artikel sekaligus
    var articles []Article
    db.Where("id IN ?", articleIDs).Find(&articles)

    // Kumpulkan semua author IDs yang unik
    authorIDSet := make(map[string]bool)
    for _, a := range articles {
        authorIDSet[a.AuthorID] = true
    }
    authorIDs := maps.Keys(authorIDSet)

    // Ambil semua author sekaligus
    var authors []User
    db.Where("id IN ?", authorIDs).Find(&authors)

    // Buat map untuk lookup yang efisien
    authorMap := make(map[string]User)
    for _, u := range authors {
        authorMap[u.ID] = u
    }

    // Gabungkan hasil
    var result []ArticleWithAuthor
    for _, a := range articles {
        result = append(result, ArticleWithAuthor{
            Article: a,
            Author:  authorMap[a.AuthorID],
        })
    }
    return result, nil
}
// Total: 2 query, tidak peduli berapa banyak artikel

Solusi 3: DTO Projection Query #

Daripada mengambil full object lalu mengakses field-nya, gunakan SELECT yang spesifik hanya untuk field yang dibutuhkan. Ini menghindari N+1 sekaligus mengurangi data transfer.

// ANTI-PATTERN: Load full objects kemudian akses field
articles, _ := db.Find(&[]Article{})
for _, a := range articles {
    // Untuk response API yang hanya butuh title dan author name,
    // kita load semua field article dan semua field user
    response = append(response, map[string]interface{}{
        "title":  a.Title,
        "author": a.Author.Name,  // ← trigger N+1
    })
}

// BENAR: Projection query yang tepat sasaran
type ArticleListItem struct {
    ID          string `json:"id"`
    Title       string `json:"title"`
    AuthorName  string `json:"authorName"`
    PublishedAt string `json:"publishedAt"`
}

var items []ArticleListItem
db.Table("articles a").
    Select("a.id, a.title, a.published_at, u.name as author_name").
    Joins("JOIN users u ON u.id = a.author_id").
    Order("a.published_at DESC").
    Limit(50).
    Scan(&items)
// 1 query, hanya field yang dibutuhkan, tidak ada N+1

Projection query sangat efektif untuk list endpoint di mana view biasanya hanya membutuhkan subset kecil dari data. Tidak perlu load seluruh relasi jika hanya butuh nama author.

Solusi 4: DataLoader Pattern #

DataLoader adalah pattern yang dikembangkan Facebook untuk mengatasi N+1 di GraphQL resolver, tapi prinsipnya bisa diterapkan di mana saja. Ia melakukan batching dan caching secara otomatis.

// DataLoader: kumpulkan semua request dalam satu event loop tick,
// lalu eksekusi satu batch query

type UserLoader struct {
    mu      sync.Mutex
    pending []string         // kumpulan user IDs yang diminta
    result  map[string]User  // cache result
    once    sync.Once
}

// Load meminta satu user — tidak langsung query database
func (l *UserLoader) Load(userID string) (*User, error) {
    l.mu.Lock()
    l.pending = append(l.pending, userID)
    l.mu.Unlock()

    // Tunggu satu event loop tick, lalu flush semua pending request
    time.AfterFunc(1*time.Millisecond, l.flush)

    // Tunggu result
    return l.result[userID], nil
}

// Flush mengeksekusi satu batch query untuk semua ID yang pending
func (l *UserLoader) flush() {
    l.mu.Lock()
    ids := l.pending
    l.pending = nil
    l.mu.Unlock()

    // Satu query untuk semua ID
    var users []User
    db.Where("id IN ?", ids).Find(&users)

    for _, u := range users {
        l.result[u.ID] = u
    }
}

DataLoader paling berguna di GraphQL resolver (sudah dibahas di artikel GraphQL), tapi konsep batching dan per-request caching berlaku untuk konteks apapun.


N+1 di Luar ORM — Pola yang Sering Terlewat #

N+1 tidak hanya terjadi melalui ORM. Pola serupa bisa muncul dalam kode yang tidak menggunakan ORM sama sekali.

// N+1 dalam service layer tanpa ORM
func (s *OrderService) GetOrdersWithDetails(userID string) ([]OrderDetail, error) {
    orders, _ := s.orderRepo.GetByUserID(userID)

    var details []OrderDetail
    for _, order := range orders {
        // Fetch product untuk setiap order item
        for _, item := range order.Items {
            product, _ := s.productRepo.GetByID(item.ProductID)  // ← N+1!
            item.Product = product
        }
        details = append(details, OrderDetail{Order: order})
    }
    return details, nil
}

// Solusi: Kumpulkan semua product IDs, fetch sekaligus
func (s *OrderService) GetOrdersWithDetails(userID string) ([]OrderDetail, error) {
    orders, _ := s.orderRepo.GetByUserID(userID)

    // Kumpulkan semua product IDs dari semua order items
    productIDs := make([]string, 0)
    for _, order := range orders {
        for _, item := range order.Items {
            productIDs = append(productIDs, item.ProductID)
        }
    }

    // Batch query — satu query untuk semua products
    products, _ := s.productRepo.GetByIDs(productIDs)
    productMap := make(map[string]Product)
    for _, p := range products {
        productMap[p.ID] = p
    }

    // Gabungkan
    for i, order := range orders {
        for j, item := range order.Items {
            orders[i].Items[j].Product = productMap[item.ProductID]
        }
    }
    return toDetails(orders), nil
}

Cara Mencegah N+1 di Code Review #

Code review adalah kesempatan terbaik untuk menangkap N+1 sebelum masuk ke production. Berikut pertanyaan yang perlu ditanyakan saat review:

Pertanyaan yang perlu dijawab untuk setiap PR yang menyentuh data access:

1. "Ada loop yang di dalamnya ada akses database?"
   → Lihat setiap for loop, each(), map() yang memanggil repository atau ORM

2. "Kalau ada 1000 item, berapa query yang dihasilkan?"
   → Hitung secara mental: 1 query utama + N query per item = masalah

3. "Apakah relasi yang diakses sudah di-preload?"
   → Jika ada `article.Author.Name` tapi tidak ada Preload("Author"), alarm!

4. "Apakah ada bukti SQL log untuk endpoint ini?"
   → Minta developer untuk lampirkan SQL log dari development environment

5. "Apakah ada test yang mengecek jumlah query?"
   → Verifikasi bahwa behavior efisien di-maintain seiring perubahan kode
// Test yang memverifikasi jumlah query
func TestGetArticles_QueryCount(t *testing.T) {
    db := setupTestDB()

    // Seed test data
    for i := 0; i < 20; i++ {
        createTestArticleWithAuthor(db, i)
    }

    // Hitung query yang dieksekusi
    queryCount := 0
    db.Callback().Query().After("gorm:query").Register("test_counter",
        func(db *gorm.DB) { queryCount++ })

    repo := NewArticleRepository(db)
    articles, err := repo.ListWithAuthors(ctx, 20)
    assert.NoError(t, err)
    assert.Len(t, articles, 20)

    // Verifikasi tidak ada N+1: harus ≤ 3 query (articles + authors + tags)
    assert.LessOrEqual(t, queryCount, 3,
        "Expected at most 3 queries, got %d — possible N+1", queryCount)
}
N+1 yang sudah ada di production sangat sulit diperbaiki tanpa risiko regresi karena perubahan query behavior bisa mempengaruhi data yang ditampilkan. Deteksi di code review atau development jauh lebih murah dari perbaikan di production. Jadikan “apakah ada N+1?” sebagai pertanyaan standar di setiap PR yang menyentuh data access.

Perbandingan Solusi #

Solusi           Kapan Digunakan                         Trade-off
─────────────────────────────────────────────────────────────────────
Eager Loading    Relasi selalu dibutuhkan bersama parent  Bisa over-fetch jika relasi tidak selalu diperlukan
JOIN Query       Butuh filter berdasarkan relasi          Query lebih kompleks, tapi sangat efisien
Batch Query      Kontrol lebih atas strategi fetch        Lebih banyak kode tapi fleksibel
DTO Projection   Endpoint list yang butuh subset field    Best performance, tapi lebih verbose
DataLoader       GraphQL resolver, async context          Kompleks tapi paling fleksibel untuk dynamic queries
Cache            Data relasi jarang berubah               Cache invalidation complexity, solusi tambahan bukan utama

Checklist N+1 Query #

DEVELOPMENT:
  □ SQL logging diaktifkan saat development
  □ Setiap loop yang mengakses database dicurigai dan diperiksa
  □ Total query count dicek untuk setiap endpoint yang dibuat

CODE REVIEW:
  □ Tanya "berapa query jika ada 1000 item?" untuk setiap PR data access
  □ Verifikasi bahwa relasi yang diakses sudah di-eager-load
  □ Minta SQL log sebagai bukti jika ada keraguan

TESTING:
  □ Test yang menghitung jumlah query ada untuk endpoint kritis
  □ Test dijalankan dengan dataset yang lebih besar dari minimal
  □ Perubahan ke query strategy mengupdate assertion query count

PRODUCTION MONITORING:
  □ pg_stat_statements atau equivalent diaktifkan
  □ Alert untuk query yang dieksekusi terlalu sering (> threshold per menit)
  □ APM menampilkan query count per request endpoint
  □ Slow query log diaktifkan dengan threshold yang wajar (> 100ms)

POLA YANG SELALU DICURIGAI:
  □ Loop + akses relasi ORM → kemungkinan N+1
  □ Loop + repository call di dalam loop → kemungkinan N+1
  □ GraphQL resolver tanpa DataLoader → hampir pasti N+1
  □ "Lazy load" yang tidak eksplisit di-disable → risiko N+1

Ringkasan #

  • N+1 adalah masalah O(n) terhadap data, bukan O(n) terhadap request — 100 concurrent request dengan N+1 bisa menghasilkan 10.000+ query, sementara tanpa N+1 hanya 100 query.
  • N+1 tidak terlihat di development tapi menyakitkan di production — dataset kecil menyembunyikan masalah ini. Aktifkan SQL logging dan periksa polanya saat development.
  • Loop + database access adalah alarm — setiap kali ada loop yang di dalamnya terdapat akses database, pertanyakan apakah ini N+1.
  • Eager loading adalah solusi paling umum — preload relasi yang hampir selalu dibutuhkan. ORM modern sudah mengoptimalkan ini dengan batch IN query, bukan join yang mahal.
  • JOIN untuk filter, Preload untuk display — gunakan JOIN jika butuh filter berdasarkan data relasi, Preload jika hanya butuh data relasi untuk ditampilkan.
  • DTO projection untuk list endpoint — endpoint yang menampilkan list biasanya hanya butuh sebagian kecil field. Query yang spesifik menghindari N+1 sekaligus mengurangi data transfer.
  • Batch dengan IN clause jika tidak pakai ORM — kumpulkan semua IDs yang dibutuhkan, fetch sekaligus dengan WHERE id IN (...), lalu buat map untuk lookup O(1).
  • DataLoader untuk GraphQL dan async context — pattern batching dan caching per-request yang sangat efektif untuk resolver yang dipanggil berkali-kali.
  • Code review adalah garis pertahanan terbaik — pertanyaan “berapa query jika ada 1000 item?” harus menjadi pertanyaan standar di setiap PR yang menyentuh data access.
  • Test yang memverifikasi jumlah query — assertion assert.LessOrEqual(t, queryCount, 3) mencegah N+1 muncul kembali akibat refactoring di masa depan.

← Sebelumnya: DB Transaction   Berikutnya: Idempotency →

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