Optimasi Query Django ORM: Hentikan N+1, Ukur Dulu

Panduan praktis mengukur dan memperbaiki N+1 di Django ORM dengan eager loading, field selection, agregasi, index, dan EXPLAIN.

Hook

Aplikasi Django terasa lambat setelah jumlah data dan traffic mulai naik. Endpoint yang sebelumnya selesai dalam 50 ms kini membutuhkan satu atau dua detik. CPU server tampak santai, tetapi database bekerja terlalu keras.

Sering kali penyebabnya bukan query yang kompleks. Penyebabnya justru query sederhana yang dieksekusi terlalu banyak.

orders = Order.objects.filter(user=request.user)

for order in orders:
    print(order.customer.name)

Query pertama mengambil daftar Order. Namun setiap akses order.customer dapat memicu query tambahan. Dengan 100 order, satu request bisa menghasilkan 101 query: satu query untuk orders dan 100 query untuk customer masing-masing order. Inilah pola N+1 query.

Django ORM menyediakan alat untuk mengatasinya, tetapi optimasi yang aman bukanlah menambahkan select_related() ke semua queryset. Mulailah dengan mengukur, temukan query yang benar-benar mahal, perbaiki sesuai bentuk relasinya, lalu ukur kembali.

Problem dan Context

N+1 tidak selalu terlihat dari kode

N+1 bisa muncul di view, serializer, template, task, atau GraphQL resolver.

class OrderSerializer(serializers.ModelSerializer):
    customer_name = serializers.CharField(
        source="customer.name",
        read_only=True,
    )

    class Meta:
        model = Order
        fields = ["id", "total", "customer_name"]

Serializer terlihat sederhana. Namun jika queryset tidak memuat customer lebih awal, setiap object Order bisa menyebabkan query tambahan. Hal serupa terjadi pada template dan resolver GraphQL yang membaca nested fields. Untuk konteks integrasi GraphQL dengan Django, lihat juga panduan Strawberry dan Django.

Ukur sebelum mengubah kode

Gunakan assertNumQueries untuk menangkap regresi di test:

from django.test import TestCase

class OrderQueryTests(TestCase):
    def test_order_list_does_not_create_n_plus_one(self):
        with self.assertNumQueries(1):
            orders = list(
                Order.objects
                .filter(status="paid")
                .select_related("customer")
            )

            for order in orders:
                _ = order.customer.name

Untuk observasi lokal, django-debug-toolbar menampilkan jumlah query, durasi, dan query duplikat. Di production, gunakan database logging atau APM. Perhatikan jumlah query, total durasi, query yang paling sering muncul, dan query dengan scan besar. Query sekali yang memakan 800 ms bisa lebih penting daripada 50 query kecil dengan total 20 ms.

Solution Walkthrough

select_related() cocok untuk ForeignKey, OneToOneField, dan forward relation yang menghasilkan satu object. Django mengambilnya dengan SQL JOIN:

orders = (
    Order.objects
    .filter(status="paid")
    .select_related("customer", "billing_address")
)

for order in orders:
    customer_name = order.customer.name
    city = order.billing_address.city

Nested relation juga dapat disebutkan:

orders = Order.objects.select_related("customer__company")

Hindari pemanggilan tanpa argumen (select_related()). Bentuk ini deprecated dan dijadwalkan dihapus pada Django 7.0; selain itu, ia dapat mengikuti seluruh relasi non-null dan menghasilkan JOIN yang tidak perlu. Selalu sebutkan relation secara eksplisit, lalu ukur hasilnya:

queryset = Order.objects.select_related("customer", "billing_address")

prefetch_related() cocok untuk reverse ForeignKey, ManyToManyField, dan relation yang lebih tepat diambil lewat query terpisah:

orders = (
    Order.objects
    .filter(status="paid")
    .select_related("customer")
    .prefetch_related("items")
)

for order in orders:
    print(order.customer.name)
    for item in order.items.all():
        print(item.product_id, item.quantity)

Contoh tersebut masih dapat menghasilkan N+1 untuk item.product. Gabungkan select_related di dalam Prefetch:

from django.db.models import Prefetch

items = (
    OrderItem.objects
    .select_related("product")
    .only(
        "id", "order_id", "product_id",
        "product__name", "quantity",
    )
)

orders = (
    Order.objects
    .filter(status="paid")
    .select_related("customer")
    .prefetch_related(Prefetch("items", queryset=items))
)

Jika hanya item aktif yang diperlukan, beri nama hasilnya dengan to_attr:

active_items = OrderItem.objects.filter(is_cancelled=False)
orders = Order.objects.prefetch_related(
    Prefetch("items", queryset=active_items, to_attr="active_items")
)

for order in orders:
    for item in order.active_items:
        print(item.quantity)

Filter berbeda pada order.items.all() dapat mengabaikan cache prefetch dan menjalankan query baru. Tentukan kontrak data dengan jelas.

3. Batasi kolom dengan only, defer, dan values

Gunakan only() jika object model tetap diperlukan tetapi endpoint hanya membaca subset field:

orders = (
    Order.objects
    .select_related("customer")
    .only(
        "id", "status", "total", "customer_id",
        "customer__id", "customer__name",
    )
)

Field yang tidak dimuat dapat memicu query tambahan ketika diakses:

orders = Order.objects.only("id", "status")
for order in orders:
    print(order.total)  # may issue a deferred-field query

defer() cocok untuk mengecualikan field besar:

articles = Article.objects.defer("body", "raw_html")

Untuk response read-only, values() sering lebih sederhana:

orders = (
    Order.objects
    .filter(status="paid")
    .values("id", "status", "total", "customer__name")
)

for order in orders:
    print(order["id"], order["customer__name"])

Gunakan values_list() untuk hasil ringan seperti ID:

customer_ids = (
    Order.objects
    .filter(status="paid")
    .values_list("customer_id", flat=True)
    .distinct()
)

4. Biarkan database mengerjakan operasi set-based

Untuk jumlah row, gunakan count() tanpa memuat seluruh object:

total_orders = Order.objects.filter(customer=customer).count()

Untuk pengecekan keberadaan, gunakan exists():

has_pending = Order.objects.filter(
    customer=customer,
    status="pending",
).exists()

Gunakan F() agar update dilakukan di database dan mengurangi risiko lost update:

from django.db.models import F

Product.objects.filter(stock__gt=0).update(
    stock=F("stock") - 1,
)

Untuk data agregat, gunakan annotate():

from django.db.models import Count, Q, Sum

customers = Customer.objects.annotate(
    paid_order_count=Count(
        "orders",
        filter=Q(orders__status="paid"),
        distinct=True,
    ),
    paid_order_total=Sum(
        "orders__total",
        filter=Q(orders__status="paid"),
    ),
)

Periksa hasil agregasi ketika beberapa relation di-join sekaligus. Join tambahan dapat menggandakan row; distinct=True membantu untuk count, tetapi tidak otomatis memperbaiki semua agregasi.

Untuk order terakhir per customer, gunakan subquery:

from django.db.models import OuterRef, Subquery

latest_order_total = (
    Order.objects
    .filter(customer_id=OuterRef("pk"))
    .order_by("-created_at")
    .values("total")[:1]
)

customers = Customer.objects.annotate(
    latest_order_total=Subquery(latest_order_total)
)

5. Periksa SQL dan query plan

Setelah jumlah query membaik, lihat cara database mengeksekusinya:

queryset = (
    Order.objects
    .filter(status="paid", customer_id=customer_id)
    .select_related("customer")
)

print(queryset.explain())

Dengan PostgreSQL, opsi berikut berguna saat diagnosis:

print(queryset.explain(analyze=True, buffers=True, verbose=True))

analyze=True benar-benar menjalankan query. Gunakan dengan hati-hati pada production dan jangan menganggap output development identik dengan production. Cari Seq Scan pada tabel besar, estimasi row yang jauh dari jumlah aktual, sort atau hash join yang mahal, serta nested loop dengan iterasi tinggi.

6. Tambahkan index berdasarkan workload nyata

Index harus mengikuti pola filter, join, dan ordering yang benar-benar dominan:

class Order(models.Model):
    customer = models.ForeignKey(Customer, on_delete=models.CASCADE)
    status = models.CharField(max_length=30)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [
            models.Index(
                fields=["customer", "status", "-created_at"],
                name="order_customer_status_created_idx",
            ),
        ]

Buat migration dan ukur kembali dengan EXPLAIN. Index menambah storage dan dapat memperlambat write, jadi jangan menambahkan index hanya karena sebuah kolom sering muncul di kode. Pada PostgreSQL, partial index juga dapat tepat untuk subset stabil:

from django.db.models import Q

class Meta:
    indexes = [
        models.Index(
            fields=["customer", "-created_at"],
            name="paid_order_customer_created_idx",
            condition=Q(status="paid"),
        ),
    ]

Baca juga pattern PostgreSQL untuk Python untuk konteks database yang lebih luas.

7. Uji query budget pada boundary yang benar

Uji tempat object benar-benar digunakan, bukan hanya queryset yang belum dievaluasi:

from django.test import TestCase
from django.db.models import Count, Q

class CustomerSummaryTests(TestCase):
    def test_summary_query_count(self):
        customer = Customer.objects.create(name="Acme")
        Order.objects.create(
            customer=customer,
            status="paid",
            total="100.00",
        )

        with self.assertNumQueries(1):
            summary = (
                Customer.objects
                .filter(pk=customer.pk)
                .annotate(
                    paid_order_count=Count(
                        "orders",
                        filter=Q(orders__status="paid"),
                    )
                )
                .values("name", "paid_order_count")
                .get()
            )

        self.assertEqual(summary["paid_order_count"], 1)

Untuk GraphQL, uji query dengan nested selection set yang benar-benar digunakan client. Resolver yang aman untuk query sederhana belum tentu aman untuk orders.items.product.

Trade-offs

Jumlah query versus ukuran query

select_related() memakai JOIN dan cocok untuk relation single-valued. prefetch_related() memakai query terpisah lalu menggabungkan object di Python, sehingga cocok untuk collection. Satu query besar tidak otomatis lebih cepat daripada dua query kecil.

Eager loading versus memory

Prefetch menyimpan related object di memory. Pada dataset besar, gunakan pagination, filter, atau batch processing:

for orders in batched_order_querysets:
    process(orders)

Jangan menambahkan eager loading yang tidak dibutuhkan hanya demi mengejar angka query terendah.

Field selection versus maintainability

only() dan defer() dapat menghemat data, tetapi akses field deferred dapat menimbulkan query tersembunyi. values() lebih ringan, tetapi mengubah hasil menjadi dictionary dan tidak cocok untuk logic yang membutuhkan model methods atau properties.

ORM versus raw SQL

Django ORM menangani join, prefetch, aggregation, expression, dan subquery dengan baik. Pertimbangkan raw SQL hanya setelah melihat SQL dan query plan serta memiliki alasan yang terukur. Raw SQL menambah coupling ke database dan biaya maintenance.

Query optimization versus async

Async tidak menghilangkan N+1. Jika bottleneck adalah query berulang, perbaiki query terlebih dahulu. Untuk decision framework tentang workload I/O-bound dan concurrency, lihat kapan Django perlu dimigrasikan ke async.

Conclusion dan CTA

Workflow yang konsisten lebih penting daripada trik ORM tertentu:

  1. ukur jumlah dan durasi query;
  2. reproduksi dengan data realistis;
  3. gunakan select_related untuk single-valued relation;
  4. gunakan prefetch_related atau Prefetch untuk collection;
  5. pilih only, defer, atau values sesuai kebutuhan;
  6. gunakan count, exists, F, dan annotate untuk operasi set-based;
  7. periksa query plan dengan explain;
  8. tambahkan index berdasarkan workload;
  9. lindungi hasilnya dengan query-budget test.

Mulai dari endpoint paling lambat hari ini. Catat baseline, perbaiki satu pola N+1, lalu bandingkan latency, jumlah query, dan penggunaan memory sebelum dan sesudah. Database akan menunjukkan apakah perubahanmu benar-benar membantu.