# Django i PostgreSQL w 2026: indeksy, wyszukiwanie pełnotekstowe i pytania rekrutacyjne > Praktyczny przewodnik po optymalizacji Django z PostgreSQL: indeksy B-tree, częściowe i pokrywające, wyszukiwanie pełnotekstowe z SearchVector i GIN oraz pytania rekrutacyjne 2026. - Published: 2026-06-27 - Updated: 2026-07-06 - Author: SharpSkill - Tags: django, postgresql, database optimization, full text search, python - Reading time: 9 min --- Optymalizacja Django z PostgreSQL decyduje o tym, czy aplikacja skaluje się poza pierwszy tysiąc użytkowników, czy dławi się przy każdym widoku listy. PostgreSQL dostarcza silnik indeksowania i podsystem wyszukiwania pełnotekstowego, których większość projektów Django w ogóle nie rusza, marnując wydajność zapytań, której odzyskanie nic nie kosztuje. Ten przewodnik omawia wzorce indeksowania, narzędzia wyszukiwania z `django.contrib.postgres` oraz pytania rekrutacyjne o bazy danych, które w 2026 roku odróżniają inżyniera Django na poziomie mid od seniora. > **Najpierw pomiar, potem indeks** > > Każdy indeks przyspiesza odczyty, ale spowalnia zapisy i zajmuje miejsce na dysku. Warto uruchomić `EXPLAIN (ANALYZE, BUFFERS)` na rzeczywistym zapytaniu, dodać indeks, a następnie porównać plan. Indeks, którego planer zapytań nigdy nie wybiera, to czysty narzut przy każdym poleceniu INSERT i UPDATE. ## Podstawy indeksowania PostgreSQL w Django Domyślnie PostgreSQL tworzy indeks B-tree tylko na kluczach głównych i kolumnach z `unique=True`. Każda inna filtrowana lub sortowana kolumna korzysta ze skanu sekwencyjnego, dopóki nie powstanie dla niej indeks. Django udostępnia indeksy przez `Meta.indexes`, co jest lepszym rozwiązaniem niż starsze `db_index=True`, ponieważ w jednym miejscu obsługuje indeksy złożone, częściowe i pokrywające. Kolejność kolumn w indeksie złożonym nie jest kwestią kosmetyczną. B-tree może wykorzystać indeks do zapytania tylko wtedy, gdy filtr używa wiodącego prefiksu indeksowanych kolumn, co szczegółowo wyjaśnia [Use The Index, Luke](https://use-the-index-luke.com/sql/where-clause/the-equals-operator/concatenated-keys). Indeks na `(status, created_at)` obsłuży `WHERE status = ? ORDER BY created_at`, ale ten sam indeks nie przyspieszy zapytania filtrującego wyłącznie po `created_at`. ```python # models.py from django.db import models class Order(models.Model): customer_email = models.EmailField() status = models.CharField(max_length=20) total = models.DecimalField(max_digits=10, decimal_places=2) created_at = models.DateTimeField(auto_now_add=True) class Meta: indexes = [ # Jednokolumnowy B-tree do wyszukiwań dokładnych i zakresowych po status models.Index(fields=["status"]), # Indeks złożony: obsługuje WHERE status = ? ORDER BY created_at DESC # Kolumna wiodąca (status) musi wystąpić w filtrze, aby indeks zadziałał models.Index(fields=["status", "-created_at"]), ] ``` Indeks złożony zamienia zapytanie pulpitu w rodzaju „najnowsze oczekujące zamówienia” w pojedynczy skan indeksu, zamiast filtrowania i sortowania całej tabeli. ## Indeksy częściowe i pokrywające do zapytań celowanych Indeks częściowy obejmuje tylko wiersze spełniające warunek, dzięki czemu pozostaje mały i mieści się w pamięci. Gdy większość wierszy ma tę samą wartość, a zapytania sięgają wyłącznie po mniejszość, indeks częściowy jest radykalnie tańszy od pełnego. Indeks pokrywający idzie dalej: przez dodanie kolumn niekluczowych za pomocą `include` PostgreSQL odpowiada na zapytanie bezpośrednio z indeksu, bez sięgania do sterty tabeli, w schemacie dostępu zwanym index-only scan. ```python # models.py from django.db import models from django.db.models import Q class Order(models.Model): # pola zdefiniowane powyżej class Meta: indexes = [ # Indeks częściowy: indeksuje tylko wiersze pending, pomijając zakończoną historię models.Index( fields=["created_at"], name="pending_orders_idx", condition=Q(status="pending"), ), # Indeks pokrywający: total czytany prosto z indeksu (index-only scan) models.Index( fields=["status"], include=["total"], name="status_total_covering_idx", ), ] ``` W tabeli zamówień, w której 2% wierszy ma status pending, indeks częściowy może być pięćdziesiąt razy mniejszy od pełnego indeksu na tej samej kolumnie, a planer utrzymuje go rozgrzany w pamięci podręcznej buforów. Klauzula `include` jest opisana w dokumentacji [PostgreSQL index-only scans](https://www.postgresql.org/docs/current/indexes-index-only-scans.html) i stanowi najczęściej pomijaną korzyść w aplikacjach Django, które agregują kolumnę liczbową za filtrem po statusie. ## Wyszukiwanie pełnotekstowe w Django z PostgreSQL Sięgnięcie po `title__icontains=query` wydaje się wygodne, ale wymusza skan sekwencyjny i nie potrafi rankingować wyników według trafności. Wyszukiwanie pełnotekstowe w Django opakowuje natywne typy `tsvector` i `tsquery` z PostgreSQL przez `SearchVector`, `SearchQuery` i `SearchRank`. Wektor wyszukiwania normalizuje tekst do leksemów, usuwając słowa stopu i sprowadzając wyrazy do ich rdzeni, dzięki czemu wyszukanie „running” trafia w „run” oraz „ran”. ```python # views.py from django.contrib.postgres.search import ( SearchVector, SearchQuery, SearchRank, ) from .models import Article def search_articles(query_text): query = SearchQuery(query_text, config="english") # Waga A stawia trafienia w tytule ponad trafieniami w treści (waga B) vector = ( SearchVector("title", weight="A") + SearchVector("body", weight="B") ) return ( Article.objects.annotate(rank=SearchRank(vector, query)) .filter(rank__gte=0.1) .order_by("-rank") ) ``` Parametr `weight` przypisuje koszyki priorytetu od A (najwyższy) do D. Trafienie w tytule jest oceniane wyżej niż trafienie w treści, nawet gdy oba zawierają szukane wyrażenie, co odpowiada temu, jak użytkownicy oczekują działania wyszukiwarki. Pełne API oraz czterostopniowy model wag opisuje [dokumentacja wyszukiwania pełnotekstowego Django](https://docs.djangoproject.com/en/5.2/ref/contrib/postgres/search/). ## Indeksy GIN i SearchVectorField dla szybkiego wyszukiwania Powyższe zapytanie przelicza wektor wyszukiwania dla każdego wiersza przy każdym żądaniu, co jest w porządku dla kilku tysięcy wierszy, ale nie do przyjęcia dla miliona. Produkcyjny wzorzec przechowuje wektor w polu `SearchVectorField` i indeksuje go indeksem GIN, czyli typem indeksu, którego PostgreSQL używa dla wartości złożonych, takich jak dokumenty pełnotekstowe i JSONB. ```python # models.py from django.contrib.postgres.search import SearchVectorField from django.contrib.postgres.indexes import GinIndex from django.db import models class Article(models.Model): title = models.CharField(max_length=255) body = models.TextField() # Przechowywany, wstępnie policzony dokument wyszukiwania search_vector = SearchVectorField(null=True) class Meta: indexes = [ # GIN zamienia wyszukiwania pełnotekstowe w logarytmiczne skany indeksu GinIndex(fields=["search_vector"]), ] ``` Przechowywane pole musi pozostawać zsynchronizowane z `title` i `body`. Sygnał `post_save`, który zapisuje wektor za pomocą `.update()`, unika rekurencyjnego wyzwolenia samego siebie, ponieważ `QuerySet.update()` nie emituje sygnałów zapisu. ```python # signals.py from django.contrib.postgres.search import SearchVector from django.db.models.signals import post_save from django.dispatch import receiver from .models import Article @receiver(post_save, sender=Article) def sync_search_vector(sender, instance, **kwargs): Article.objects.filter(pk=instance.pk).update( search_vector=SearchVector("title", weight="A") + SearchVector("body", weight="B"), ) ``` Dla zespołów pracujących na Django 5.0 lub nowszym, obliczane po stronie bazy `GeneratedField` lub wyzwalacz PostgreSQL całkowicie eliminują dodatkowe UPDATE, utrzymując kolumnę wewnątrz bazy danych. Niezależnie od mechanizmu, który ją wypełnia, zapytanie filtruje wówczas indeksowane pole bezpośrednio, a indeks GIN zamienia dawny pełny skan tabeli w logarytmiczne wyszukiwanie. > **Indeksy GIN są kosztowne przy zapisie** > > Indeksy GIN przyspieszają odczyty kosztem zapisów, ponieważ każde wstawienie dotyka wielu wpisów indeksu. W tabelach o intensywnym zapisie warto ustawić parametr przechowywania `fastupdate` i monitorować rozmiar listy oczekujących, w przeciwnym razie zapisy pełnotekstowe będą się zatrzymywać za utrzymaniem indeksu. ## Wybór właściwego typu indeksu PostgreSQL B-tree jest właściwym ustawieniem domyślnym, ale PostgreSQL udostępnia kilka typów indeksów przez `django.contrib.postgres.indexes`, a wybór niewłaściwego marnuje miejsce na dysku bez przyspieszenia choćby jednego zapytania. Wybór wynika z kształtu danych oraz operatora używanego przez zapytanie, co zestawia [dokumentacja typów indeksów PostgreSQL](https://www.postgresql.org/docs/current/indexes-types.html). | Typ indeksu | Najlepszy do | Klasa Django | |------------|----------|--------------| | B-tree | Równość i zakres na kolumnach skalarnych | `models.Index` | | GIN | Wyszukiwanie pełnotekstowe, JSONB, zawieranie w tablicach | `GinIndex` | | BRIN | Ogromne tabele tylko do dopisywania, uporządkowane po kolumnie | `BrinIndex` | | Hash | Wyszukiwania wyłącznie po równości na dużych kolumnach | `HashIndex` | BRIN zasługuje na szczególną uwagę przy danych szeregów czasowych. W tabeli z dziesiątkami milionów wierszy wstawianych w kolejności znaczników czasu `BrinIndex` na `created_at` zajmuje kilka kilobajtów tam, gdzie B-tree potrzebowałby setek megabajtów, ponieważ BRIN przechowuje jedynie wartość minimalną i maksymalną dla zakresu bloków. Kompromis polega na tym, że BRIN pomaga tylko wtedy, gdy fizyczna kolejność wierszy podąża za indeksowaną kolumną, co naturalnie zachodzi dla logów i tabel zdarzeń tylko do dopisywania. ```python # models.py from django.contrib.postgres.indexes import BrinIndex from django.db import models class Event(models.Model): payload = models.JSONField() created_at = models.DateTimeField(auto_now_add=True) class Meta: indexes = [ # Maleńki indeks do skanów zakresowych na tabeli tylko do dopisywania, uporządkowanej czasowo BrinIndex(fields=["created_at"]), ] ``` Dopasowanie typu indeksu do wzorca dostępu to powracający temat rozmów na stanowiska senior, ponieważ dowodzi, że kandydat rozumuje o układzie przechowywania danych, zamiast odruchowo dodawać B-tree wszędzie. ## Pytania rekrutacyjne o bazy danych w Django w 2026 Pytania o bazy danych dominują na rozmowach na stanowiska senior w Django, ponieważ biegłość w ORM nie oznacza rozumienia tego, co ORM faktycznie generuje. Poniższe pytania odzwierciedlają to, co komisje rekrutacyjne rzeczywiście sprawdzają w 2026 roku, i naturalnie uzupełniają dostrajanie na poziomie zapytań opisane w [przewodniku optymalizacji zapytań Django ORM](/blog/django/django-orm-optimizing-queries). **Kiedy indeks złożony na `(a, b)` nie pomaga zapytaniu?** Gdy zapytanie nie filtruje po `a`. B-tree jest uporządkowany najpierw według swojej kolumny wiodącej, więc filtr wyłącznie po `b` nie może użyć indeksu. To reguła najbardziej lewego prefiksu, a kandydaci, którzy ją wyjaśniają, pokazują, że rozumieją strukturę indeksu, a nie zapamiętane przepisy. **Jaka jest różnica między `select_related` a `prefetch_related`?** `select_related` wykonuje SQL JOIN i działa dla relacji klucza obcego oraz jeden-do-jednego w pojedynczym zapytaniu. `prefetch_related` uruchamia drugie zapytanie i łączy wyniki w Pythonie, co jest wymagane dla relacji wiele-do-wielu i odwrotnych kluczy obcych. Sięgnięcie po niewłaściwą metodę albo pomija optymalizację, albo wywołuje niepotrzebne drugie zapytanie. **Czym różni się `icontains` od wyszukiwania pełnotekstowego na poziomie bazy danych?** `icontains` kompiluje się do `ILIKE '%term%'`, co nie może użyć standardowego indeksu B-tree i skanuje każdy wiersz. Wyszukiwanie pełnotekstowe dopasowuje do wstępnie policzonego `tsvector` wspartego indeksem GIN i rankinguje wyniki według trafności. To rozróżnienie jest wiarygodnym sygnałem, że kandydat wyszedł poza zabawkowe zbiory danych. **Dlaczego `count()` może być wolny na dużej tabeli i jakie są alternatywy?** PostgreSQL nie przechowuje w pamięci podręcznej liczby wierszy tabeli, więc `COUNT(*)` skanuje każdy widoczny wiersz w modelu MVCC. Dla przybliżonych liczb zapytanie do `pg_class.reltuples` zwraca oszacowanie natychmiast, a dla stronicowania paginacja kluczowa (keyset) na indeksowanej kolumnie całkowicie unika liczenia. Uporządkowaną praktykę tych wzorców udostępnia [moduł rekrutacyjny o cache w Django](/technologies/django/interview-questions/django-caching), gdzie liczniki oparte na cache są powracającym motywem, oraz cała [ścieżka nauki Django](/technologies/django). **Co ujawnia `EXPLAIN ANALYZE`, czego samo `EXPLAIN` nie pokazuje?** `EXPLAIN` wypisuje szacowany koszt planera; `EXPLAIN ANALYZE` faktycznie wykonuje zapytanie i raportuje rzeczywiste czasy oraz liczby wierszy. Duża rozbieżność między szacowaną a rzeczywistą liczbą wierszy wskazuje na nieaktualne statystyki tabeli, naprawialne poleceniem `ANALYZE`, i jest częstą przyczyną tego, że planer ignoruje indeks, który powinien oczywiście zadziałać. ## Podsumowanie - Indeksy warto definiować w `Meta.indexes` zamiast `db_index=True`, aby indeksy złożone, częściowe i pokrywające znajdowały się w jednym, łatwym do audytu miejscu. - Kolumny indeksu złożonego należy porządkować według reguły najbardziej lewego prefiksu: kolumna wiodąca musi wystąpić w filtrze zapytania, aby indeks zadziałał. - Indeksy częściowe sprawdzają się, gdy zapytania sięgają wyłącznie po mniejszość wierszy, na przykład zamówienia oczekujące w tabeli zdominowanej przez zakończoną historię. - Indeksy pokrywające z `include` umożliwiają skany index-only i pomijają dostęp do sterty przy zapytaniach agregujących o dużej liczbie odczytów. - Wyszukiwanie przez `icontains` warto zastąpić przez `SearchVector`, `SearchQuery` i `SearchRank`, aby za darmo uzyskać ranking trafności oraz sprowadzanie do rdzeni. - Dokument wyszukiwania należy przechowywać w polu `SearchVectorField` wspartym indeksem `GinIndex`, utrzymywanym w synchronizacji przez sygnał, `GeneratedField` lub wyzwalacz bazy danych, zanim wyszukiwanie pełnotekstowe trafi na skalę produkcyjną. - Decyzje o indeksach zawsze warto weryfikować poleceniem `EXPLAIN (ANALYZE, BUFFERS)`; indeks, którego planer nigdy nie wybiera, to podatek płacony przy zapisie bez korzyści przy odczycie. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/pl/blog/django/django-postgresql-indexing-full-text-search-2026