Meningkatkan kemampuan ORM Django ke level lanjutan: Q objects untuk query kompleks, F expressions untuk update atomik, subquery, window functions, dan strategi optimisasi query termasuk pemberantasan N+1 untuk aplikasi berkinerja tinggi.

Dasar-dasar ORM di episode 6 dan performa cache di episode 12 sudah membawa aplikasi jauh. Tapi ada kalanya query itu sendiri yang jadi bottleneck: data bertambah, logika filtering kompleks, dan N+1 kembali menghantui. Di episode ini kita menaikkan skill ORM ke level lanjutan — alat-alat yang membedakan "kode yang jalan" dari "kode yang cepat".
Mengapa topik ini penting? Karena di traffic tinggi, perbedaan satu query vs seratus query adalah perbedaan 200 OK dan timeout. Q objects, F expressions, subquery, dan window functions adalah empat senjata yang menyelesaikan 90% masalah performa query di dunia nyata.
filter(**kwargs) biasa hanya menggabungkan kondisi dengan AND. Untuk OR, NOT, dan kombinasi, gunakan Q objects:
from django.db.models import Q
# Post berstatus draft ATAU view_count di bawah 10
posts = Post.objects.filter(
Q(status="draft") | Q(view_count__lt=10)
)
# Filter "search" lintas banyak field
query = request.GET.get("q", "")
posts = Post.objects.filter(
Q(title__icontains=query) | Q(body__icontains=query)
).distinct()Operator: | (OR), & (AND), ~ (NOT). Q(condition) | Q(...) bisa dikombinasikan dengan kwargs biasa (yang selalu AND). distinct() perlu saat JOIN menghasilkan duplikat baris — contoh klasik saat memfilter tags__name yang banyak.
F expressions mereferensikan kolom database dalam query, bukan nilai Python. Ini krusial untuk dua alasan: update atomik (bebas race condition) dan menghindari memuat data ke memori.
from django.db.models import F
# Anti-pattern: race condition saat traffic tinggi
post = Post.objects.get(pk=1)
post.view_count += 1
post.save()
# Benar: atomik di database
Post.objects.filter(pk=1).update(view_count=F("view_count") + 1)Tanpa F, dua request bersamaan bisa membaca nilai sama (misal 10), masing-masing menulis 11 — hasil akhir 11, bukan 12. Dengan F, database mengeksekusi SET view_count = view_count + 1 secara atomik di bawah lock. Pola ini wajib untuk counter seperti view_count.
Tip
F() juga bisa dibandingkan antar kolom dalam satu query: filter(updated_at__gt=F("created_at")). Dan setelah update memakai F, ingat untuk memanggil .refresh_from_db() jika masih butuh nilai terbaru di objek yang sama — nilai di memori tidak otomatis berubah.
Untuk query yang membutuhkan nilai dari query lain, gunakan Subquery. Contoh klasik: "post yang punya komentar terbaru di 24 jam terakhir":
from django.db.models import OuterRef, Subquery
latest_comment_time = Comment.objects.filter(
post=OuterRef("pk")
).order_by("-created_at").values("created_at")[:1]
posts = Post.objects.annotate(
last_comment_at=Subquery(latest_comment_time)
).filter(last_comment_at__isnull=False)OuterRef("pk") mereferensikan post di query luar. Untuk cek keberadaan yang hemat, Exists lebih murah daripada Subquery karena berhenti saat menemukan baris pertama:
from django.db.models import Exists, OuterRef
has_comments = Comment.objects.filter(post=OuterRef("pk"))
posts = Post.objects.annotate(
has_comments=Exists(has_comments)
)Window functions menghitung agregat per-baris tanpa menggabungkan baris — beda dengan annotate biasa yang mengelompokkan. Contoh: ranking post berdasarkan view_count:
from django.db.models import F, Window
from django.db.models.functions import Rank
posts = Post.objects.annotate(
rank=Window(
expression=Rank(),
order_by=F("view_count").desc(),
partition_by=F("category"),
)
)Rank() memberi peringkat dalam grup per category. partition_by memecah window per kategori, order_by menentukan urutan peringkat. Window functions sangat berguna untuk "top N per kategori", moving average, dan cumulative sum — semua dihitung di database, bukan di Python.
Warning
Window functions adalah fitur database modern — didukung penuh PostgreSQL 9.4+, MySQL 8.0+, dan SQLite 3.25+. Kalau project masih memakai MySQL 5.7 atau SQLite lama, query ini akan error. Ini juga salah satu alasan rekomendasi Django menuju PostgreSQL untuk production.
N+1 (episode 6) kembali muncul seiring query bertambah kompleks. Senjata pertamanya adalah melihat query sebenarnya:
qs = Post.objects.select_related("author").prefetch_related("comments")
print(qs.query) # SQL mentah
from django.db import connection
print(len(connection.queries)) # jumlah query terpakaidjango-debug-toolbar (episode 15) menampilkan jumlah query per halaman di browser — deteksi N+1 jadi instan. Strategi optimalisasi mengikuti prinsip:
select_related untuk semua FK yang pasti diakses (forward).prefetch_related untuk reverse dan M2M.only/defer untuk memuat subset kolom besar (TextField) hanya saat dibutuhkan.values/values_list untuk data yang tidak perlu di-instantiate menjadi objek model.Meta untuk kolom yang difilter/diurutkan.Contoh gabungan:
posts = (
Post.objects.filter(status="published")
.select_related("author", "category")
.prefetch_related("tags")
.only("id", "title", "slug", "author_id", "category_id")
.order_by("-created_at")
)only(...) membatasi kolom yang dimuat — body (TextField besar) tidak dibawa sampai benar-benar diakses, menghemat bandwidth DB untuk list view.
Gabungkan semua teknik untuk halaman list:
from django.db.models import Count
from django.views.generic import ListView
class PostListView(ListView):
model = Post
paginate_by = 10
def get_queryset(self):
return (
super()
.get_queryset()
.filter(status="published")
.select_related("author", "category")
.annotate(comment_count=Count("comments"))
.only("id", "title", "slug", "excerpt", "author__username",
"category__name", "created_at")
)Satu query utama + agregat comment_count — tanpa N+1. Bandingkan dengan versi naif yang memuat body + query komentar per post; untuk 50 post, selisihnya bisa 1 query vs 100+ query.
Inti yang harus dibawa pulang:
|, &, ~) untuk filter kompleks; distinct() saat JOIN memunculkan duplikat.Exists lebih hemat untuk cek keberadaan.Window + Rank) untuk ranking/agregat per-baris tanpa menggabungkan baris.qs.query, connection.queries, dan debug-toolbar.select_related + prefetch_related + only = 1 query list tanpa N+1.Di episode 15 selanjutnya kita belajar melihat ke dalam aplikasi: Logging, Debugging & Testing — konfigurasi LOGGING, structured logging, Django Debug Toolbar, TestCase dengan client test, pytest-django, dan coverage. Sampai jumpa di episode 15!