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 sekaligusAngka 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 memilikipg_stat_statementsextension yang mencatat statistics semua query yang dieksekusi, termasuk berapa kali query yang sama dieksekusi. Ini sangat berguna untuk mendeteksi N+1 di production: querySELECT * FROM users WHERE id = $1yang 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.