# Django e PostgreSQL nel 2026: indicizzazione, ricerca full-text e domande da colloquio > Guida pratica all'ottimizzazione di Django con PostgreSQL: indici B-tree, parziali e covering, ricerca full-text con SearchVector e GIN, più le domande da colloquio del 2026. - Published: 2026-06-27 - Updated: 2026-07-06 - Author: SharpSkill - Tags: django, postgresql, database optimization, full text search, python - Reading time: 9 min --- L'ottimizzazione di Django con PostgreSQL segna la differenza tra un'applicazione che scala oltre i primi mille utenti e una che si blocca a ogni vista elenco. PostgreSQL include un motore di indicizzazione e un sottosistema di ricerca full-text che la maggior parte dei progetti Django lascia inutilizzati, sprecando prestazioni sulle query che non costa nulla recuperare. Questa guida illustra i pattern di indicizzazione, gli strumenti di ricerca di `django.contrib.postgres` e le domande da colloquio sui database che distinguono gli ingegneri Django di livello intermedio da quelli senior nel 2026. > **Misurare prima di indicizzare** > > Ogni indice accelera le letture ma rallenta le scritture e occupa spazio su disco. Conviene eseguire `EXPLAIN (ANALYZE, BUFFERS)` sulla query reale, aggiungere l'indice e poi confrontare il piano. Un indice che il pianificatore non sceglie mai è puro sovraccarico su ogni INSERT e UPDATE. ## Fondamenti dell'indicizzazione PostgreSQL in Django Per impostazione predefinita, PostgreSQL crea un indice B-tree solo sulle chiavi primarie e sulle colonne con `unique=True`. Ogni altra colonna filtrata o ordinata esegue una scansione sequenziale finché non esiste un indice. Django espone gli indici tramite `Meta.indexes`, preferibile al più vecchio `db_index=True` perché consente di definire in un unico punto indici compositi, parziali e covering. L'ordine delle colonne all'interno di un indice composito non è un dettaglio estetico. Un B-tree può usare un indice per una query solo quando il filtro sfrutta un prefisso iniziale delle colonne indicizzate, una regola spiegata in dettaglio su [Use The Index, Luke](https://use-the-index-luke.com/sql/where-clause/the-equals-operator/concatenated-keys). Un indice su `(status, created_at)` serve per `WHERE status = ? ORDER BY created_at`, ma lo stesso indice non può accelerare una query che filtra solo su `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 = [ # B-tree a colonna singola per lookup esatti e per intervallo su status models.Index(fields=["status"]), # Indice composito: serve per WHERE status = ? ORDER BY created_at DESC # La colonna iniziale (status) deve comparire nel filtro per essere usata models.Index(fields=["status", "-created_at"]), ] ``` L'indice composito trasforma una query di dashboard come "ultimi ordini in sospeso" in una singola scansione d'indice, invece di un filtro seguito da ordinamento sull'intera tabella. ## Indici parziali e covering per query mirate Un indice parziale copre solo le righe che soddisfano una condizione, quindi rimane piccolo e resta in memoria. Quando la maggior parte delle righe condivide un valore e le query interessano solo la minoranza, un indice parziale è molto più economico di uno completo. Un indice covering va oltre: aggiungendo colonne non chiave con `include`, PostgreSQL risponde alla query direttamente dall'indice senza accedere allo heap della tabella, un pattern di accesso chiamato index-only scan. ```python # models.py from django.db import models from django.db.models import Q class Order(models.Model): # campi definiti come sopra class Meta: indexes = [ # Indice parziale: indicizza solo le righe pending, ignorando lo storico completato models.Index( fields=["created_at"], name="pending_orders_idx", condition=Q(status="pending"), ), # Indice covering: total viene letto direttamente dall'indice (index-only scan) models.Index( fields=["status"], include=["total"], name="status_total_covering_idx", ), ] ``` Su una tabella di ordini in cui il 2% delle righe è in stato pending, l'indice parziale può risultare cinquanta volte più piccolo di un indice completo sulla stessa colonna, e il pianificatore lo mantiene caldo nella buffer cache. La clausola `include` è documentata in [PostgreSQL index-only scans](https://www.postgresql.org/docs/current/indexes-index-only-scans.html) ed è il vantaggio più trascurato nelle applicazioni Django che aggregano una colonna numerica dietro un filtro di stato. ## Ricerca full-text in Django con PostgreSQL Ricorrere a `title__icontains=query` sembra comodo, ma impone una scansione sequenziale e non è in grado di ordinare i risultati per rilevanza. La ricerca full-text di Django incapsula i tipi nativi `tsvector` e `tsquery` di PostgreSQL tramite `SearchVector`, `SearchQuery` e `SearchRank`. Un vettore di ricerca normalizza il testo in lessemi, eliminando le stop word e riducendo le parole alla loro radice, così che una ricerca di "running" corrisponda a "run" e "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") # Il peso A classifica le corrispondenze nel titolo sopra quelle nel corpo (peso 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") ) ``` Il parametro `weight` assegna livelli di priorità da A (il più alto) a D. Una corrispondenza nel titolo viene classificata sopra una nel corpo anche quando entrambe contengono il termine cercato, in linea con il comportamento che gli utenti si aspettano da una ricerca. L'API completa e il modello di ponderazione a quattro livelli sono descritti nella [documentazione della ricerca full-text di Django](https://docs.djangoproject.com/en/5.2/ref/contrib/postgres/search/). ## Indici GIN e SearchVectorField per ricerche veloci La query precedente ricalcola il vettore di ricerca per ogni riga a ogni richiesta, cosa accettabile per qualche migliaio di righe ma inaccettabile per un milione. Il pattern di produzione memorizza il vettore in un `SearchVectorField` e lo indicizza con un indice GIN, il tipo di indice che PostgreSQL usa per valori composti come i documenti full-text e i campi 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() # Documento di ricerca memorizzato e pre-calcolato search_vector = SearchVectorField(null=True) class Meta: indexes = [ # GIN trasforma le ricerche full-text in scansioni d'indice logaritmiche GinIndex(fields=["search_vector"]), ] ``` Il campo memorizzato deve restare sincronizzato con `title` e `body`. Un segnale `post_save` che scrive il vettore con `.update()` evita di attivare il segnale in modo ricorsivo, poiché `QuerySet.update()` non emette segnali di salvataggio. ```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"), ) ``` Per i team su Django 5.0 o versioni successive, un `GeneratedField` calcolato dal database o un trigger PostgreSQL elimina del tutto l'UPDATE aggiuntivo, mantenendo la colonna all'interno del database. Qualunque sia il meccanismo che la mantiene popolata, la query filtra poi direttamente il campo indicizzato, e l'indice GIN trasforma quella che era una scansione completa della tabella in una ricerca logaritmica. > **Gli indici GIN sono costosi in scrittura** > > Gli indici GIN accelerano le letture a un costo reale in scrittura, poiché ogni inserimento tocca molte voci dell'indice. Sulle tabelle con molte scritture conviene impostare il parametro di storage `fastupdate` e monitorare la dimensione della pending list, altrimenti le scritture full-text rimarranno bloccate dietro la manutenzione dell'indice. ## Scegliere il tipo di indice PostgreSQL corretto Il B-tree è l'impostazione predefinita corretta, ma PostgreSQL espone diversi tipi di indice tramite `django.contrib.postgres.indexes`, e sceglierne uno sbagliato spreca spazio su disco senza migliorare una singola query. La scelta dipende dalla forma dei dati e dall'operatore usato dalla query, come catalogato nella [documentazione dei tipi di indice di PostgreSQL](https://www.postgresql.org/docs/current/indexes-types.html). | Tipo di indice | Ideale per | Classe Django | |------------|----------|--------------| | B-tree | Uguaglianza e intervalli su colonne scalari | `models.Index` | | GIN | Ricerca full-text, JSONB, contenimento in array | `GinIndex` | | BRIN | Tabelle enormi append-only ordinate per una colonna | `BrinIndex` | | Hash | Lookup di sola uguaglianza su colonne grandi | `HashIndex` | BRIN merita un'attenzione particolare per i dati di tipo time-series. Su una tabella di decine di milioni di righe inserite in ordine di timestamp, un `BrinIndex` su `created_at` occupa pochi kilobyte laddove un B-tree ne richiederebbe centinaia di megabyte, perché BRIN memorizza solo il valore minimo e massimo per ciascun intervallo di blocchi. Il compromesso è che BRIN aiuta solo quando l'ordine fisico delle righe segue la colonna indicizzata, condizione che vale naturalmente per log append-only e tabelle di eventi. ```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 = [ # Indice minuscolo per scansioni per intervallo su una tabella append-only ordinata per tempo BrinIndex(fields=["created_at"]), ] ``` Abbinare il tipo di indice al pattern di accesso è un tema ricorrente nei colloqui per posizioni senior, perché dimostra che il candidato ragiona sul layout di archiviazione invece di aggiungere B-tree ovunque per riflesso. ## Domande da colloquio Django sui database nel 2026 Le domande sui database dominano i colloqui Django senior perché la padronanza dell'ORM non implica la comprensione di ciò che l'ORM genera. Le domande che seguono riflettono ciò che le commissioni di selezione indagano davvero nel 2026 e si abbinano naturalmente al tuning a livello di query trattato nella [guida all'ottimizzazione delle query con l'ORM di Django](/blog/django/django-orm-optimizing-queries). **Quando un indice composito su `(a, b)` non aiuta una query?** Quando la query non filtra su `a`. Un B-tree è ordinato prima per la sua colonna iniziale, quindi un filtro solo su `b` non può usare l'indice. Questa è la regola del prefisso più a sinistra, e i candidati che la spiegano dimostrano di comprendere la struttura degli indici anziché ricette imparate a memoria. **Qual è la differenza tra `select_related` e `prefetch_related`?** `select_related` esegue una JOIN SQL e funziona per relazioni a chiave esterna e uno-a-uno in un'unica query. `prefetch_related` esegue una seconda query e unisce i risultati in Python, cosa necessaria per le relazioni molti-a-molti e per le chiavi esterne inverse. Scegliere quello sbagliato fa perdere l'ottimizzazione oppure innesca una seconda query non necessaria. **In cosa differisce `icontains` dalla ricerca full-text a livello di database?** `icontains` si traduce in `ILIKE '%term%'`, che non può usare un normale indice B-tree e scansiona ogni riga. La ricerca full-text confronta con un `tsvector` pre-calcolato supportato da un indice GIN e ordina i risultati per rilevanza. La distinzione è un segnale affidabile che il candidato è andato oltre i dataset giocattolo. **Perché `count()` può essere lento su una tabella di grandi dimensioni e quali sono le alternative?** PostgreSQL non conserva un conteggio delle righe memorizzato per una tabella, quindi `COUNT(*)` scansiona ogni riga visibile secondo l'MVCC. Per conteggi approssimati, interrogare `pg_class.reltuples` restituisce una stima all'istante, mentre per la paginazione la keyset pagination su una colonna indicizzata evita del tutto il conteggio. Una pratica strutturata su questi pattern è disponibile nel [modulo di colloquio sul caching in Django](/technologies/django/interview-questions/django-caching), dove i contatori basati su cache sono un tema ricorrente, e in tutto il [percorso di apprendimento Django](/technologies/django). **Cosa rivela `EXPLAIN ANALYZE` che `EXPLAIN` da solo non mostra?** `EXPLAIN` stampa il costo stimato dal pianificatore; `EXPLAIN ANALYZE` esegue effettivamente la query e riporta tempi e conteggi di righe reali. Uno scarto ampio tra righe stimate ed effettive indica statistiche di tabella obsolete, correggibili con `ANALYZE`, ed è una causa comune del fatto che il pianificatore ignora un indice che dovrebbe ovviamente applicarsi. ## Conclusione - Definire gli indici in `Meta.indexes` anziché con `db_index=True`, così che indici compositi, parziali e covering risiedano in un unico punto verificabile. - Ordinare le colonne degli indici compositi secondo la regola del prefisso più a sinistra: la colonna iniziale deve comparire nel filtro della query perché l'indice venga applicato. - Usare indici parziali quando le query interessano solo una minoranza di righe, come gli ordini in sospeso in una tabella dominata dallo storico completato. - Aggiungere indici covering con `include` per abilitare gli index-only scan ed evitare l'accesso allo heap nelle query aggregate a forte carico di letture. - Sostituire la ricerca con `icontains` con `SearchVector`, `SearchQuery` e `SearchRank` per ottenere gratuitamente l'ordinamento per rilevanza e lo stemming. - Memorizzare il documento di ricerca in un `SearchVectorField` supportato da un `GinIndex`, mantenuto sincronizzato da un segnale, da un `GeneratedField` o da un trigger del database, prima che la ricerca full-text raggiunga la scala di produzione. - Convalidare sempre le decisioni sugli indici con `EXPLAIN (ANALYZE, BUFFERS)`; un indice che il pianificatore non seleziona mai è una tassa in fase di scrittura senza alcun beneficio in fase di lettura. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/it/blog/django/django-postgresql-indexing-full-text-search-2026