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(...)) | |
|---|---|---|
| SQL | GROUP BY | OVER (PARTITION BY ...) |
| Filas conservadas | Una por grupo | Todas |
| Acceso a los campos | Solo los incluidos en values() | Todos |
| Encadenabilidad | Filtro sobre grupos (HAVING) | Filtro vía subconsulta (WHERE sobre las filas) |
select_related() | ❌ | ✅ |
| Caso de uso | Contar, sumar, máximo global | Rango, 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.