Avec Django ORM, il existe deux façons d’ajouter une valeur calculée sur un ensemble de lignes : annotate() avec une agrégation classique (Max, Count, Sum…) ou annotate() avec une Window function. En surface, elles se ressemblent. En pratique, elles ont un comportement fondamentalement différent, et choisir la mauvaise peut bloquer toute la chaîne de filtrage.
GROUP BY avec annotate() : des lignes qui s’écrasent
Quand on combine values() et annotate() avec une agrégation, Django génère un GROUP BY en SQL. Le résultat : les lignes se regroupent, et on obtient une ligne par groupe.
from django.db.models import Max
def get_latest_dates(self) -> QuerySet:
return self.values('ctr_id').annotate(
latest_date=Max('evt_end_effect_date')
)
SQL généré :
SELECT ctr_id, MAX(evt_end_effect_date) AS latest_date
FROM events
GROUP BY ctr_id
Le résultat est un dictionnaire par groupe {'ctr_id': 1, 'latest_date': date(2024, 12, 31)}. Plus d’instances de modèle complètes, juste les champs agrégés.
Ce qu’il faut comprendre sur la chaînabilité : on peut encore appeler .filter() ou .exclude() après, mais la sémantique change radicalement. Les filtres s’appliquent sur les groupes agrégés, pas sur les lignes d’origine. On ne filtre plus des événements individuels, on filtre des résultats de groupe.
# ⚠️ Ce filter s'applique sur les groupes, pas les lignes sources
self.values('ctr_id').annotate(latest_date=Max('evt_end_effect_date')).filter(
latest_date__gte=date(2024, 1, 1)
)
# SQL : HAVING MAX(evt_end_effect_date) >= '2024-01-01'
# Pas de select_related(), pas d'accès aux autres champs de la ligne
Window functions : annoter sans toucher aux lignes
Une Window function calcule une valeur sur une partition de lignes, mais garde toutes les lignes intactes. Chaque ligne reçoit sa valeur calculée comme une annotation supplémentaire.
from django.db.models import F, Window
from django.db.models.functions import FirstValue
def with_latest_dates(self) -> QuerySet:
return self.annotate(
latest_date=Window(
expression=FirstValue('evt_end_effect_date'),
partition_by=['ctr_id'],
order_by=F('evt_end_effect_date').desc(),
)
)
SQL généré :
SELECT *,
FIRST_VALUE(evt_end_effect_date) OVER (
PARTITION BY ctr_id
ORDER BY evt_end_effect_date DESC
) AS latest_date
FROM events
Toutes les lignes sont présentes. Chacune a maintenant latest_date, soit la date la plus récente de son groupe ctr_id. Et le QuerySet reste un QuerySet normal.
# ✅ Tout reste possible après une Window annotation
qs = self.with_latest_dates()
qs.filter(status='active') # filtre normal (WHERE sur colonne non-window)
qs.select_related('contract') # jointure normale
qs.exclude(latest_date__isnull=True) # filtre sur l'annotation window -> sous-requête
qs.order_by('ctr_id', '-latest_date')
Django GROUP BY vs Window Function : comparaison visuelle
GROUP BY Window function
────────────────────────────────── ──────────────────────────────────
ctr_id=1, evt=A → ligne 1 ctr_id=1, evt=A, latest=A → ligne 1
ctr_id=1, evt=B → ctr_id=1, evt=B, latest=A → ligne 2
ctr_id=1, evt=C → MAX(C) ──► C ctr_id=1, evt=C, latest=A → ligne 3
ctr_id=2, evt=D → ligne 2 ctr_id=2, evt=D, latest=D → ligne 4
ctr_id=2, evt=E → MAX(E) ──► E ctr_id=2, evt=E, latest=D → ligne 5
GROUP BY compresse. Window annote.
Cas d’usage concret : récupérer la ligne la plus récente par groupe
Objectif : pour chaque contrat, récupérer l’événement avec la date de fin d’effet la plus récente, avec accès à tous ses champs.
Avec GROUP BY, c’est impossible directement : on perd les champs de la ligne. Avec Window + RowNumber :
from django.db.models import F, Window
from django.db.models.functions import RowNumber
def get_latest_event_per_contract(self) -> QuerySet:
return (
self.annotate(
row_num=Window(
expression=RowNumber(),
partition_by=['ctr_id'],
order_by=F('evt_end_effect_date').desc(),
)
)
.filter(row_num=1)
.select_related('contract')
)
RowNumber() numérote les lignes dans chaque partition, triées par date décroissante. filter(row_num=1) garde uniquement la première, c’est-à-dire la plus récente. Django ne peut pas ajouter un WHERE direct sur une Window function (impossible en SQL standard), il génère donc une sous-requête : SELECT * FROM (...) "qualify" WHERE "row_num" = 1.
Window functions disponibles dans Django
from django.db.models.functions import (
FirstValue, # première valeur de la partition
LastValue, # dernière valeur
Lag, # valeur N lignes en arrière : Lag('field', offset=1)
Lead, # valeur N lignes en avant : Lead('field', offset=1)
NthValue, # nième valeur : NthValue('field', nth=2)
Rank, # rang avec ex-aequo (1, 1, 3)
DenseRank, # rang sans trous (1, 1, 2)
RowNumber, # numéro de ligne unique par partition
CumeDist, # distribution cumulée (0.0 → 1.0)
PercentRank, # rang relatif (0.0 → 1.0)
Ntile, # découpage en N buckets : Ntile(num_buckets=4)
)
GROUP BY ou Django Window Function : tableau de décision
values().annotate(Max(...)) | annotate(Window(...)) | |
|---|---|---|
| SQL | GROUP BY | OVER (PARTITION BY ...) |
| Lignes conservées | Une par groupe | Toutes |
| Accès aux champs | Seulement ceux dans values() | Tous |
| Chaînabilité | Filtre sur groupes (HAVING) | Filtre via sous-requête (WHERE sur les lignes) |
select_related() | ❌ | ✅ |
| Cas d’usage | Compter, sommer, max global | Rang, valeur voisine, max par ligne |
La règle simple : si tu as besoin de garder les lignes complètes et de continuer à filtrer normalement après ton calcul, utilise une Window function. Si tu veux uniquement des résultats agrégés (stats, totaux, max globaux), values().annotate() est plus direct et plus lisible.
Tu travailles sur l’optimisation ORM Django ? J’ai aussi écrit sur Django in_bulk() : pourquoi c’est mieux que filter() en masse.