PostgreSQL
Agregaciones y GROUP BY
Agregaciones con COUNT, SUM, AVG, MIN y MAX, agrupación con GROUP BY, filtros HAVING y tratamiento correcto de NULL y cardinalidad.
- Última actualización
- Actualizada
- Nivel
- Fundamentos
PostgreSQL
Agregaciones con COUNT, SUM, AVG, MIN y MAX, agrupación con GROUP BY, filtros HAVING y tratamiento correcto de NULL y cardinalidad.
Las aggregate functions reciben un conjunto de filas y producen un valor:
muchas filas
→ count / sum / avg / min / max
→ un resultadoGROUP BY divide las filas en conjuntos independientes. Sin GROUP BY, toda la entrada forma un único grupo.
Ejemplo:
SELECT
branch_id,
count(*) AS order_count,
sum(total_amount) AS revenue
FROM orders
WHERE status = 'paid'
GROUP BY branch_id;El resultado tiene una fila por branch_id, no una fila por order.
Los sistemas necesitan responder preguntas como:
Sin agregaciones, la aplicación tendría que descargar todas las filas y resumirlas. Eso aumenta red, memoria y ventanas de inconsistencia. PostgreSQL puede procesar los datos cerca del almacenamiento y elegir algoritmos hash, sort o paralelos.
El riesgo principal es perder de vista la granularidad. Si un join multiplica cada order antes del SUM, PostgreSQL sumará exactamente las filas que recibió, aunque no representen la métrica pretendida.
Consulta base:
orders
→ una fila por orderDespués:
GROUP BY branch_idNueva granularidad:
una fila por branchDespués:
GROUP BY branch_id, date_trunc('day', paid_at)Nueva granularidad:
una fila por branch y díaToda columna de SELECT debe:
Cuenta filas, aunque todas sus columnas sean NULL.
SELECT count(*) FROM orders;Cuenta resultados no NULL:
SELECT count(delivered_at) FROM orders;Suma valores no NULL. El tipo de salida puede ser más amplio que el tipo de entrada; revisa documentación para integer, bigint y numeric.
Calcula promedio de valores no NULL. No equivale siempre a sum / count en tipos y precisión exactos de aplicación.
Encuentran extremos según operadores y collation.
Resumen condiciones booleanas:
SELECT bool_and(status = 'paid') FROM orders;Construyen colecciones. Necesitan orden interno si la secuencia importa.
SELECT
count(*) AS rows,
count(discount_amount) AS known_discounts,
sum(discount_amount) AS discount_total
FROM orders
WHERE false;Resultado conceptual:
count(*) → 0
count(column) → 0
sum(column) → NULL
avg/min/max → NULLSi el contrato define que “sin descuentos” equivale a total cero:
COALESCE(sum(discount_amount), 0::numeric)No uses COALESCE automáticamente. Para avg, NULL puede comunicar que no existe muestra.
SELECT status, count(*)
FROM orders
GROUP BY status;NULL forma su propio grupo porque GROUP BY utiliza semántica de agrupación donde valores NULL se consideran equivalentes para ese propósito.
Para agrupar tiempo:
SELECT
date_trunc('day', paid_at AT TIME ZONE 'America/Bogota') AS local_day,
sum(total_amount)
FROM orders
WHERE status = 'paid'
GROUP BY local_day;Debes definir la zona. Agrupar directamente un timestamptz por día depende de TimeZone de sesión si no haces explícita la conversión.
Filtra filas antes de agrupar:
WHERE status = 'paid'Filtra grupos después de calcularlos:
GROUP BY customer_id
HAVING sum(total_amount) > 1000000;Usar HAVING para una condición que podría ir en WHERE obliga a agrupar filas innecesarias y puede cambiar semántica.
Ejemplo:
HAVING customer_id = $1puede moverse a WHERE si solo quieres ese customer.
Pero:
HAVING count(*) >= 5solo existe después de agrupar.
SELECT
count(*) AS total,
count(*) FILTER (WHERE status = 'paid') AS paid,
count(*) FILTER (WHERE status = 'cancelled') AS cancelled,
sum(total_amount) FILTER (WHERE status = 'paid') AS revenue
FROM orders;FILTER permite varias métricas condicionales sobre la misma entrada.
Comparación con CASE:
sum(CASE WHEN status = 'paid' THEN total_amount ELSE 0 END)Ambas son válidas. FILTER suele expresar mejor la intención y evita definir un valor artificial para filas excluidas.
count(DISTINCT customer_id)Cuenta customers distintos.
Coste:
No lo uses como reparación automática.
string_agg(name, ', ' ORDER BY name)
array_agg(id ORDER BY created_at, id)
jsonb_agg(payload ORDER BY sequence_number)Un ORDER BY externo ordena filas del resultado, no los elementos dentro del array/string. Si el orden interno importa, decláralo dentro del agregado.
SELECT
o.id,
jsonb_agg(
jsonb_build_object(
'productId', oi.product_id,
'quantity', oi.quantity,
'unitPrice', oi.unit_price
)
ORDER BY oi.product_id
) AS items
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id;Ventajas:
Costes:
[null].Alternativa:
COALESCE(
jsonb_agg(...) FILTER (WHERE oi.order_id IS NOT NULL),
'[]'::jsonb
)Problema:
order
→ 3 items
→ 2 payments
→ join produce 6 filasSolución:
WITH item_totals AS (
SELECT order_id, sum(quantity * unit_price) AS total
FROM order_items
GROUP BY order_id
),
payment_totals AS (
SELECT order_id, sum(amount) AS paid
FROM payments
GROUP BY order_id
)
SELECT o.id, it.total, pt.paid
FROM orders o
LEFT JOIN item_totals it ON it.order_id = o.id
LEFT JOIN payment_totals pt ON pt.order_id = o.id;Cada CTE produce una fila por order antes de combinar.
Promedio de precios:
avg(unit_price)responde “promedio por línea”, no “precio promedio por unidad vendida”.
Weighted:
sum(unit_price * quantity) / NULLIF(sum(quantity), 0)La definición de la métrica debe preceder al SQL.
SELECT
branch_id,
status,
sum(total_amount) AS total,
GROUPING(branch_id) AS branch_is_total,
GROUPING(status) AS status_is_total
FROM orders
GROUP BY GROUPING SETS (
(branch_id, status),
(branch_id),
(status),
()
);Produce detalle y subtotales en una sola consulta.
ROLLUP(a,b) genera jerarquía:
(a,b), (a), ()CUBE(a,b) genera todas las combinaciones.
NULL de subtotal puede confundirse con NULL real de datos; GROUPING() permite distinguirlo.
Usa hash por group key. Bueno si los grupos caben en memoria; puede usar batches/disco en versiones modernas cuando excede recursos.
Necesita entrada ordenada y procesa grupos consecutivos. Puede aprovechar índice o sort existente.
En planes paralelos, workers calculan parciales y un nodo final combina.
EXPLAIN (ANALYZE, BUFFERS) revela algoritmo, memoria, batches y spills.
Un índice no vuelve instantáneo cualquier count(*). PostgreSQL debe respetar MVCC y visibilidad.
Índices ayudan cuando:
Para dashboards repetidos, considera materialized views o tablas agregadas solo después de medir.
SELECT
branch_id,
date_trunc('day', paid_at AT TIME ZONE $1) AS local_day,
count(*) AS orders,
count(DISTINCT customer_id) AS customers,
sum(total_amount) AS revenue,
avg(total_amount) AS average_ticket
FROM orders
WHERE status = 'paid'
AND paid_at >= $2
AND paid_at < $3
GROUP BY branch_id, local_day
ORDER BY local_day, branch_id;Decisiones:
No aparece ningún grupo si existe GROUP BY. Para mostrar branches con cero ventas, parte de branches y LEFT JOIN el agregado.
Crea un grupo NULL.
Revisa tipo de salida y casts. Dinero suele usar numeric.
Inflará sum/count. Audita cardinalidad.
Pueden agotar memoria o superar límites de respuesta. Pagina children o consulta separadamente.
Un reporte diario puede cambiar si pagos llegan tarde. Define cierre y reconciliación.
Ignora NULL.
Procesa más datos y confunde intención.
Puede esconder un join incorrecto; DISTINCT expresa mejor deduplicación pura.
Duplica métricas.
Responde una pregunta distinta.
El array/string puede cambiar de secuencia.
GROUP BY
→ colapsa filas
→ una fila por grupo
window function
→ conserva filas
→ añade cálculo sobre filas relacionadasPara mostrar cada order junto con total de su customer, una window evita agrupar y volver a unir.
Subqueries, EXISTS y LATERAL permite construir relaciones intermedias, expresar existencia y ejecutar consultas dependientes por fila.