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(...))
SQLGROUP BYOVER (PARTITION BY ...)
Lignes conservéesUne par groupeToutes
Accès aux champsSeulement ceux dans values()Tous
ChaînabilitéFiltre sur groupes (HAVING)Filtre via sous-requête (WHERE sur les lignes)
select_related()
Cas d’usageCompter, sommer, max globalRang, 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.