Krótka odpowiedź
W Django indeksy "naprawdę działają" wtedy, gdy rozwiązują konkretny problem zapytania: filtr, sortowanie albo lookup po funkcji.
Nie działają wtedy, gdy są dodawane hurtowo bez pomiaru, bo każdy indeks podnosi koszt INSERT, UPDATE i DELETE.
Dobra strategia to: najpierw pomiar, potem jeden celowany indeks, potem ponowny pomiar i decyzja keep/drop.
Co znaczy, że indeks działa
W tym artykule indeks uznajemy za skuteczny tylko wtedy, gdy spełnia jednocześnie trzy warunki:
- obniża p95 latency dla konkretnego endpointu lub zapytania,
- redukuje koszt planu i/lub odczyty buforów (
BUFFERS) wEXPLAIN ANALYZE, - nie podnosi kosztu write path ponad uzgodniony budżet.
Jeśli poprawa dotyczy tylko benchmarku lokalnego, a produkcyjny write path zaczyna się dusić, indeks jest nietrafiony.
Flow doboru indeksu (od danych do decyzji)

Ten proces wymusza decyzję na podstawie planu zapytania, a nie na podstawie listy "modnych indeksów".
1) Zacznij od realnych zapytań, nie od modeli
Użyj pg_stat_statements, żeby zobaczyć zapytania o najwyższym łącznym koszcie (total_exec_time) albo najwyższym czasie jednostkowym (mean_exec_time).
Dopiero potem przejdź do ORM i sprawdź plan przez QuerySet.explain(), najlepiej na danych zbliżonych do produkcyjnych.
qs = (
Order.objects
.filter(tenant_id=tenant_id, status="PAID")
.order_by("-created_at")
)
print(qs.explain(analyze=True, buffers=True, verbose=True))Wynik planu traktuj jako kontrakt wejściowy do decyzji o indeksie.
2) B-Tree: domyślny wybór dla większości API OLTP
W PostgreSQL B-Tree to domyślny i najczęściej właściwy typ dla porównań i sortowania.
Dla indeksów wielokolumnowych krytyczna jest kolejność: ograniczenia po lewej stronie (leftmost) najmocniej wpływają na skuteczność skanu.
Dlatego dla zapytania WHERE tenant_id = ? AND status = ? ORDER BY created_at DESC naturalnym kandydatem jest:
Index(fields=["tenant_id", "status", "-created_at"], name="ord_tenant_status_created_ix")
Trade-off: ten indeks przyspieszy read path, ale zwiększy koszt zapisu każdego rekordu Order.
3) Partial index: gdy "gorący" jest tylko fragment danych
Jeśli większość ruchu dotyczy małego podzbioru (np. tylko status='PAID'), partial index zwykle daje lepszy stosunek zysku do kosztu niż pełny indeks na całej tabeli.
W Django możesz to zapisać przez condition=Q(...).
Index(fields=["tenant_id", "-created_at"], condition=Q(status="PAID"), name="ord_paid_recent_ix")
Ważne: planner użyje partial index tylko wtedy, gdy warunek zapytania implikuje predykat indeksu. Przy zapytaniach parametryzowanych łatwo to zepsuć i wtedy indeks nie będzie używany.
4) Expression index: dla lookupów opartych o funkcję
Jeżeli filtrujesz po LOWER(email) albo innej funkcji, zwykły indeks na email może nie pomóc.
Wtedy potrzebny jest indeks na wyrażeniu:
Index(Lower("email"), name="ord_email_lower_ix")
To eliminuje konieczność obliczania funkcji podczas skanu indeksu i porównań, ale zwiększa koszt utrzymania indeksu przy zapisach i aktualizacjach.
5) Covering index (INCLUDE): mniej heap fetchów, ale bez gwarancji
INCLUDE pozwala dołożyć kolumny tylko do odczytu, bez udziału w kluczu wyszukiwania.
To bywa bardzo skuteczne dla endpointów listujących dane, bo planner może wybrać index-only scan.
Ale index-only scan zależy od visibility map. Jeśli strony nie są oznaczone jako all-visible, PostgreSQL i tak wykona heap lookup.
Przykład:
Index(fields=["tenant_id", "status"], include=["total_gross", "currency"], name="ord_cover_ix")
W Django include działa na PostgreSQL i jest ignorowane na innych backendach.
6) GIN i BRIN: narzędzia specjalistyczne, nie domyślne
GIN wybieraj dla danych złożonych: jsonb, array, hstore, tsvector.
W Django najczęściej będzie to GinIndex dla JSONField.
BRIN ma sens przy bardzo dużych tabelach append-only, gdzie wartości kolumny są skorelowane z fizyczną kolejnością danych (np. created_at).
W zwykłym workloadzie transactional API B-Tree będzie częściej trafionym wyborem niż BRIN.
Przykład Meta.indexes w Django
from django.contrib.postgres.indexes import GinIndex
from django.db import models
from django.db.models import Q
from django.db.models.functions import Lower
class Order(models.Model):
tenant_id = models.UUIDField()
status = models.CharField(max_length=20)
created_at = models.DateTimeField()
email = models.EmailField()
total_gross = models.DecimalField(max_digits=12, decimal_places=2)
currency = models.CharField(max_length=3)
metadata = models.JSONField(default=dict)
class Meta:
indexes = [
models.Index(
fields=["tenant_id", "status", "-created_at"],
name="ord_tenant_status_created_ix",
),
models.Index(
fields=["tenant_id", "-created_at"],
condition=Q(status="PAID"),
name="ord_paid_recent_ix",
),
models.Index(
Lower("email"),
name="ord_email_lower_ix",
),
models.Index(
fields=["tenant_id", "status"],
include=["total_gross", "currency"],
name="ord_cover_ix",
),
GinIndex(
fields=["metadata"],
name="ord_meta_gin_ix",
),
]Ten zestaw ma sens tylko wtedy, gdy odpowiada konkretnym top-zapytaniom. Jeśli nie masz takich zapytań, to jest over-indexing.
Najczęstsze antywzorce
- Dodawanie indeksu do każdego pola z
WHEREbez sprawdzania częstotliwości zapytań. - Dublowanie indeksów o niemal identycznym prefiksie kolumn.
- Ignorowanie kosztu write path po wdrożeniu.
- Zakładanie, że partial index zadziała dla każdego wariantu zapytania.
- Traktowanie
INCLUDEjako gwarancji index-only scan.
Checklista rolloutu produkcyjnego
- Złap top 5 zapytań z
pg_stat_statementsdla ostatnich 24-72h. - Dla każdego zapytania zapisz baseline: p95, plan,
BUFFERS, liczba wywołań. - Dodaj jeden indeks na iterację i wdrażaj poza peak traffic.
- Po wdrożeniu porównaj metryki read i write.
- Jeśli poprawa jest marginalna lub koszt zapisu rośnie zbyt mocno, usuń indeks.
To podejście zwykle daje mniejszy zestaw indeksów, ale o realnie wysokim ROI.
Finalny werdykt
W Django/PostgreSQL indeksy, które naprawdę działają, to nie "jak najwięcej indeksów", tylko "najmniejszy zestaw indeksów pasujących do najdroższych zapytań".
Zacznij od B-Tree i poprawnej kolejności kolumn, partial i expression dodawaj tam, gdzie masz twarde dane, a GIN/BRIN traktuj jako rozwiązania specjalistyczne.
Indeks to decyzja architektoniczna o koszcie utrzymania, nie jednorazowy trik na benchmark.
