Il tuo ORM ti mente
Considera questo codice. Sembra ragionevole:
progetti = Progetto.objects.all()
for progetto in progetti:
print(progetto.responsabile.nome)Due righe. Pulite, leggibili, idiomatiche. In produzione con 200 progetti eseguono 201 query sul database — una per recuperare i progetti, una per ogni responsabile. È il problema N+1, ed è la causa più comune di performance degradate in applicazioni Django che funzionano benissimo in sviluppo e diventano lente in produzione.
Il problema non è Django. È che l'ORM nasconde il costo reale delle operazioni, e se non sai cosa sta generando, non puoi ottimizzarlo.

Vedere il problema: django-debug-toolbar e logging SQL
Il primo strumento è la visibilità. Django può loggare ogni query eseguita durante una request — basta configurare il logger:
LOGGING = {
"version": 1,
"handlers": {
"console": {"class": "logging.StreamHandler"},
},
"loggers": {
"django.db.backends": {
"level": "DEBUG",
"handlers": ["console"],
},
},
}In sviluppo, django-debug-toolbar è ancora più immediato: mostra il numero di query per ogni request, il tempo di esecuzione, e il SQL generato. Se una view semplice esegue 50 query, lo vedi immediatamente.
In produzione non puoi loggare tutto, ma puoi usare connection.queries in modo selettivo per misurare sezioni critiche:
from django.db import connection, reset_queries
reset_queries()
print(len(connection.queries))N+1: riconoscerlo e risolverlo
Il problema N+1 si manifesta ogni volta che accedi a una relazione dentro un loop senza averla pre-caricata. Django carica ogni relazione lazily — al momento dell'accesso, non al momento della query iniziale.
select_related
Risolve le relazioni ForeignKey e OneToOne con una singola query JOIN:
progetti = Progetto.objects.all()
for p in progetti:
print(p.responsabile.nome)Con select_related:
progetti = Progetto.objects.select_related("responsabile").all()
for p in progetti:
print(p.responsabile.nome)prefetch_related
Risolve le relazioni ManyToMany e reverse ForeignKey con una seconda query separata (non un JOIN):
progetti = Progetto.objects.all()
for p in progetti:
tags = p.tags.all()Con prefetch_related:
progetti = Progetto.objects.prefetch_related("tags").all()
for p in progetti:
tags = p.tags.all()La regola pratica: select_related per ForeignKey e OneToOne, prefetch_related per ManyToMany e reverse relations. Puoi combinarli sulla stessa queryset.
Un errore comune è usare prefetch_related e poi filtrare i risultati dentro il loop — questo invalida la cache e genera di nuovo N query. Se hai bisogno di filtrare, usa Prefetch con un queryset custom:
from django.db import models
progetti = Progetto.objects.prefetch_related(
models.Prefetch(
"documenti",
queryset=Documento.objects.filter(stato="approvato"),
to_attr="documenti_approvati"
)
)Caricare solo quello che serve
Ogni campo che carichi ha un costo — memoria, serializzazione, trasferimento. Se hai bisogno solo di alcuni campi, dillo esplicitamente.
values()
Restituisce dizionari invece di istanze ORM — più veloce, nessun overhead di istanziazione oggetti:
progetti = Progetto.objects.values("id", "titolo")values_list()
Restituisce tuple, utile per liste di ID o export:
ids = Progetto.objects.values_list("id", flat=True)only()
Carica istanze ORM ma con solo i campi specificati — gli altri vengono caricati lazily se acceduti (attenzione: può generare N+1 se non ci pensi):
progetti = Progetto.objects.only("id", "titolo", "stato")defer()
È l'inverso — carica tutto tranne i campi specificati. Utile per escludere campi pesanti come TextField o JSONField su modelli con molti record:
progetti = Progetto.objects.defer("descrizione_lunga", "metadata_json")Capire cosa succede davvero: EXPLAIN ANALYZE
Ottimizzare le query Django senza guardare il query plan di PostgreSQL è come debuggare al buio. EXPLAIN ANALYZE esegue la query e mostra esattamente come PostgreSQL l'ha eseguita — quale piano ha scelto, quanto tempo ha impiegato ogni passo, quante righe ha processato.
from django.db import connection
with connection.cursor() as cursor:
cursor.execute(
"EXPLAIN ANALYZE SELECT * FROM progetti_progetto WHERE stato = %s",
["attivo"]
)
rows = cursor.fetchall()
for row in rows:
print(row[0])L'output da sapere leggere:
Seq Scan on progetti_progetto (cost=0.00..45.50 rows=2 width=842)
(actual time=0.023..0.387 rows=2 loops=1)
Filter: ((stato)::text = 'attivo'::text)
Rows Removed by Filter: 198Seq Scan
Significa che PostgreSQL ha letto l'intera tabella riga per riga. Con 200 righe è accettabile. Con 200.000 è un problema grave. La soluzione quasi sempre è un indice.
Index Scan
Significa che PostgreSQL ha usato un indice per trovare direttamente le righe — molto più efficiente su tabelle grandi.
cost
È una stima interna di PostgreSQL (non in millisecondi). Il secondo valore è il costo totale stimato — più è alto, più è costosa la query.
actual time
Sono i millisecondi reali. Se actual time è molto diverso da cost, PostgreSQL aveva statistiche obsolete — esegui ANALYZE nome_tabella per aggiornarle.
Gli indici che mancano quasi sempre
In Django, ogni ForeignKey ha automaticamente un indice. Ma ci sono casi comuni dove mancano:
Campi usati nei filtri frequenti
class Progetto(models.Model):
stato = models.CharField(max_length=20, db_index=True)
data_scadenza = models.DateField(db_index=True)Indici composti
Per query che filtrano su più campi insieme:
class Meta:
indexes = [
models.Index(fields=["stato", "data_scadenza"]),
models.Index(fields=["responsabile", "stato"]),
]Indici parziali
Per query su sottoinsiemi frequenti — più piccoli e più veloci degli indici completi:
from django.db.models import Index, Q
class Meta:
indexes = [
Index(
fields=["data_scadenza"],
condition=Q(stato="attivo"),
name="idx_scadenza_attivi"
)
]Indici su JSONField
Se filtri su chiavi specifiche di un campo JSON, puoi creare un indice funzionale direttamente con una migration custom:
from django.db import migrations
class Migration(migrations.Migration):
operations = [
migrations.RunSQL(
"CREATE INDEX idx_metadata_tipo ON progetti_progetto ((metadata->>'tipo'))",
reverse_sql="DROP INDEX idx_metadata_tipo"
)
]
]Quando l'ORM non basta
L'ORM di Django copre il 95% dei casi. Quel 5% che non copre bene sono le query analitiche complesse, le window functions, i CTE (Common Table Expressions), e le aggregazioni articolate.
raw()
Permette SQL puro restituendo istanze ORM — utile quando vuoi i metodi del modello ma hai bisogno di una query che l'ORM non può esprimere:
progetti = Progetto.objects.raw("""
SELECT p.*, COUNT(d.id) as num_documenti
FROM progetti_progetto p
LEFT JOIN documenti_documento d ON d.progetto_id = p.id
GROUP BY p.id
ORDER BY num_documenti DESC
""")connection.execute()
Per query che non ritornano istanze ORM — aggregazioni, update bulk, operazioni DDL:
from django.db import connection
with connection.cursor() as cursor:
cursor.execute("""
UPDATE progetti_progetto
SET stato = 'scaduto'
WHERE data_scadenza < NOW() AND stato = 'attivo'
""")
print(f"Aggiornati {cursor.rowcount} progetti")Usare SQL diretto non è una resa — è la scelta consapevole di uno strumento migliore per un problema specifico. L'importante è farlo con criterio: parametrizza sempre i valori (mai concatenare stringhe), documenta perché hai scelto SQL invece dell'ORM, e testa il query plan con EXPLAIN ANALYZE.
Il processo che funziona
Ottimizzare le query Django non è un'attività una-tantum. È un processo:
- In sviluppo, usa django-debug-toolbar su ogni view che tocca il database
- Prima del deploy, controlla con EXPLAIN ANALYZE le query sui modelli più grandi
- Aggiungi indici solo dove ci sono problemi reali misurati, non preventivamente
- Monitora i query time in produzione — Sentry, Datadog, o anche il semplice middleware di logging Django
L'ORM è uno strumento potente — ma come tutti gli strumenti potenti, funziona meglio quando capisci cosa sta facendo sotto la superficie.