Masalah query N+1 terjadi ketika Django ORM mengeksekusi satu query SQL untuk mengambil kumpulan data induk, kemudian mengeksekusi query tambahan untuk setiap baris data guna mengambil relasi terkait. Karakteristik lazy loading pada Django ORM membuat masalah ini kerap lolos dari pengujian unit biasa, karena pengujian fungsional umumnya hanya memeriksa validitas payload JSON tanpa memvalidasi jumlah query SQL yang ditembakkan ke database.

Akar Masalah: Lazy Loading pada Django REST Framework (DRF)

Pada DRF, serializer mengakses relasi objek secara otomatis saat melakukan serialisasi data. Pertimbangkan model relasional berikut:

from django.db import models

class Author(models.Model):
    name = models.CharField(max_length=100)

class Tag(models.Model):
    name = models.CharField(max_length=50)

class Book(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
    tags = models.ManyToManyField(Tag, related_name="books")

Ketika serializer DRF mengakses relasi author dan tags tanpa pemuatan awal (eager loading), Django mengeksekusi query baru setiap kali loop serializer berjalan:

from rest_framework import serializers

class BookSerializer(serializers.ModelSerializer):
    author_name = serializers.CharField(source="author.name", read_only=True)
    tag_list = serializers.SlugRelatedField(
        many=True, read_only=True, slug_field="name", source="tags"
    )

    class Meta:
        model = Book
        fields = ["id", "title", "author_name", "tag_list"]

Jika endpoint mengembalikan 50 buku, kode di atas mengeksekusi 1 query untuk Book, 50 query untuk Author, dan 50 query untuk Tag, menghasilkan total 101 query (1 + 2N). Solusinya adalah menerapkan select_related untuk relasi foreign key (menggunakan SQL JOIN) dan prefetch_related untuk relasi many-to-many (menggunakan SQL IN terpisah):

from rest_framework.viewsets import ReadOnlyModelViewSet

class BookViewSet(ReadOnlyModelViewSet):
    serializer_class = BookSerializer

    def get_queryset(self):
        return Book.objects.select_related("author").prefetch_related("tags")

Dengan optimasi ini, jumlah query berkurang menjadi tepat 2 query, berapapun jumlah baris data yang dikembalikan.

Mendeteksi Regresi: Django TestCase vs pytest-django

Untuk memastikan optimasi query tidak rusak akibat perubahan kode di masa mendatang, jumlah query harus diuji secara terprogram.

1. Menggunakan Django TestCase Standar

Django menyediakan method bawaan assertNumQueries pada kelas TestCase:

from django.test import TestCase
from django.urls import reverse
from .models import Author, Book, Tag

class BookAPITest(TestCase):
    @classmethod
    def setUpTestData(cls):
        author = Author.objects.create(name="Martin Fowler")
        tag = Tag.objects.create(name="Architecture")
        for i in range(10):
            book = Book.objects.create(title=f"Book {i}", author=author)
            book.tags.add(tag)

    def test_book_list_query_count(self):
        url = reverse("book-list")
        # 1 query untuk books + author, 1 query untuk prefetch tags
        with self.assertNumQueries(2):
            response = self.client.get(url)
        self.assertEqual(response.status_code, 200)

2. Menggunakan pytest-django

Jika menggunakan pytest, gunakan fixture django_assert_num_queries dari paket pytest-django:

import pytest
from django.urls import reverse

@pytest.mark.django_db
def test_book_list_query_budget(client, django_assert_num_queries, create_books):
    create_books(count=15)
    url = reverse("book-list")
    
    with django_assert_num_queries(2):
        response = client.get(url)
        
    assert response.status_code == 200

Context Manager Khusus: Debugging dan Query Budget

Kendala utama assertNumQueries standar adalah kegagalan hanya memunculkan perbedaan angka (misal: Expected 2, got 12) tanpa menampilkan query mana yang bocor. Manfaatkan CaptureQueriesContext untuk mencetak trace query SQL ketika limit terlampaui:

from contextlib import contextmanager
from django.db import connection
from django.test.utils import CaptureQueriesContext

@contextmanager
def assert_max_queries(max_queries: int):
    with CaptureQueriesContext(connection) as ctx:
        yield ctx
        executed = len(ctx)
        if executed > max_queries:
            queries_log = "\n".join(
                f"[{idx + 1}] {q['sql']} ({q['time']}s)"
                for idx, q in enumerate(ctx.captured_queries)
            )
            raise AssertionError(
                f"Query budget exceeded! Allowed: {max_queries}, Executed: {executed}\n"
                f"Executed Queries:\n{queries_log}"
            )

Menangani Query Non-Deterministik

Tes query sering kali menjadi flaky karena query non-deterministik dari middleware otentikasi, pengecekan permission, atau caching internal Django. Berikut langkah isolasinya:

  • Otentikasi & Session: Gunakan force_authenticate (DRF) atau lakukan pemanggilan awal (warm-up request) sebelum blok penangkapan query untuk memuat user dan session ke dalam cache memori koneksi.
  • Content Types & Permissions: Django memuat permission tabel via ContentType saat pertama kali diakses. Pastikan objek user telah di-preload sebelum context assertion dijalankan.
from rest_framework.test import APIClient

def test_deterministic_authenticated_endpoint(django_assert_num_queries, user):
    client = APIClient()
    # force_authenticate memotong evaluasi session/token SQL di tengah assertion
    client.force_authenticate(user=user)
    
    with django_assert_num_queries(2):
        response = client.get("/api/books/")
        
    assert response.status_code == 200

Otomatisasi di CI Pipeline

Jalankan pemeriksaan regresi query di pipeline CI (seperti GitHub Actions) pada setiap pull request untuk memblokir kode yang melanggar batas query.

name: Django Regression Tests

on: [pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15-alpine
        env:
          POSTGRES_DB: test_db
          POSTGRES_USER: user
          POSTGRES_PASSWORD: password
        ports:
          - 5432:5432
        options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5

    steps:
      - uses: actions/checkout@v4
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          
      - name: Install dependencies
        run: |
          pip install -r requirements.txt
          
      - name: Run Query Budget Tests
        env:
          DATABASE_URL: postgres://user:password@localhost:5432/test_db
        run: |
          pytest -m "django_db" tests/test_performance.py --tb=short

Catatan: Jalankan regression test database terhadap engine database yang identik dengan production (misal: PostgreSQL). Evaluasi optimasi query pada SQLite lokal dapat berbeda perilakunya terhadap engine produksi karena penanganan nested joins dan query planning yang berbeda.