Con el ORM de Django hay dos formas de añadir un valor calculado sobre un conjunto de filas: annotate() con una agregación clásica (Max, Count, Sum…) o annotate() con una Window function. A simple vista se parecen. En la práctica, su comportamiento es fundamentalmente distinto — y elegir la opción equivocada puede bloquear toda la cadena de filtrado.

GROUP BY con annotate(): filas que se comprimen

Cuando combinamos values() y annotate() con una agregación, Django genera un GROUP BY en SQL. El resultado: las filas se agrupan y obtenemos una fila por grupo.

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

SELECT ctr_id, MAX(evt_end_effect_date) AS latest_date
FROM events
GROUP BY ctr_id

El resultado es un diccionario por grupo {'ctr_id': 1, 'latest_date': date(2024, 12, 31)} — ya no hay instancias de modelo completas, solo los campos agregados.

Lo que hay que entender sobre el encadenamiento: aún es posible llamar a .filter() o .exclude() después, pero la semántica cambia radicalmente. Los filtros se aplican sobre los grupos agregados, no sobre las filas originales. Ya no filtramos eventos individuales, filtramos resultados de grupo.

# ⚠️ Este filter se aplica sobre los grupos, no sobre las filas fuente
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'
# Sin select_related(), sin acceso a los demás campos de la fila

Window functions: anotar sin tocar las filas

Una Window function calcula un valor sobre una partición de filas, pero conserva todas las filas intactas. Cada fila recibe su valor calculado como una anotación adicional.

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

SELECT *,
       FIRST_VALUE(evt_end_effect_date) OVER (
           PARTITION BY ctr_id
           ORDER BY evt_end_effect_date DESC
       ) AS latest_date
FROM events

Todas las filas están presentes. Cada una tiene ahora latest_date — la fecha más reciente de su grupo ctr_id. Y el QuerySet sigue siendo un QuerySet normal.

# ✅ Todo sigue siendo posible tras una anotación Window
qs = self.with_latest_dates()
qs.filter(status='active')          # filtro normal (WHERE sobre columna no-window)
qs.select_related('contract')       # join normal
qs.exclude(latest_date__isnull=True)  # filtro sobre la anotación window -> subconsulta
qs.order_by('ctr_id', '-latest_date')

Django GROUP BY vs Window Function: comparación visual

GROUP BY                              Window function
──────────────────────────────────    ──────────────────────────────────
ctr_id=1, evt=A → fila 1             ctr_id=1, evt=A, latest=A → fila 1
ctr_id=1, evt=B →                    ctr_id=1, evt=B, latest=A → fila 2
ctr_id=1, evt=C →  MAX(C) ──►  C    ctr_id=1, evt=C, latest=A → fila 3
ctr_id=2, evt=D → fila 2             ctr_id=2, evt=D, latest=D → fila 4
ctr_id=2, evt=E →  MAX(E) ──►  E    ctr_id=2, evt=E, latest=D → fila 5

GROUP BY comprime. Window anota.

Caso de uso concreto: recuperar la fila más reciente por grupo

Objetivo: para cada contrato, recuperar el evento con la fecha de fin de efecto más reciente, con acceso a todos sus campos.

Con GROUP BY es imposible de forma directa — se pierden los campos de la fila. Con 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() numera las filas dentro de cada partición, ordenadas por fecha descendente. filter(row_num=1) conserva únicamente la primera — la más reciente. Django no puede añadir un WHERE directo sobre una Window function (imposible en SQL estándar), así que genera una subconsulta: SELECT * FROM (...) "qualify" WHERE "row_num" = 1.

Window functions disponibles en Django

from django.db.models.functions import (
    FirstValue,   # primer valor de la partición
    LastValue,    # último valor
    Lag,          # valor N filas atrás: Lag('field', offset=1)
    Lead,         # valor N filas adelante: Lead('field', offset=1)
    NthValue,     # enésimo valor: NthValue('field', nth=2)
    Rank,         # rango con empates (1, 1, 3)
    DenseRank,    # rango sin saltos (1, 1, 2)
    RowNumber,    # número de fila único por partición
    CumeDist,     # distribución acumulada (0.0 → 1.0)
    PercentRank,  # rango relativo (0.0 → 1.0)
    Ntile,        # división en N buckets: Ntile(num_buckets=4)
)

GROUP BY o Django Window Function: tabla de decisión

values().annotate(Max(...))annotate(Window(...))
SQLGROUP BYOVER (PARTITION BY ...)
Filas conservadasUna por grupoTodas
Acceso a los camposSolo los incluidos en values()Todos
EncadenabilidadFiltro sobre grupos (HAVING)Filtro vía subconsulta (WHERE sobre las filas)
select_related()
Caso de usoContar, sumar, máximo globalRango, valor vecino, máximo por fila

La regla sencilla: si necesitas conservar las filas completas y seguir filtrando con normalidad después del cálculo, usa una Window function. Si solo quieres resultados agregados (estadísticas, totales, máximos globales), values().annotate() es más directo y más legible.


¿Trabajas en la optimización del ORM de Django? También escribí sobre Django in_bulk(): por qué es mejor que filter() en masa.