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.

Django ORM e PostgreSQL: dalle query avanzate all'ottimizzazione reale

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: 198

Seq 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.