En esta página

Funciones de agregación

14 min lectura TextoCap. 2 — Consultas básicas

Funciones de agregación

Las funciones de agregación transforman un conjunto de filas en un único valor resumen. Son la base de cualquier sistema de reportes, dashboard analítico o estadística de negocio. Con ellas puedes responder preguntas como: "¿cuántos pedidos se realizaron este mes?", "¿cuál es el producto más vendido?", "¿qué categoría genera más ingresos?".

Las cinco funciones de agregación fundamentales

-- Datos de ejemplo: tabla ventas
CREATE TABLE ventas (
  id          SERIAL        PRIMARY KEY,
  producto_id INTEGER       NOT NULL,
  cantidad    INTEGER       NOT NULL CHECK (cantidad > 0),
  precio_unit NUMERIC(10,2) NOT NULL,
  vendedor_id INTEGER,
  vendido_en  TIMESTAMPTZ   NOT NULL DEFAULT NOW()
);

INSERT INTO ventas (producto_id, cantidad, precio_unit, vendedor_id) VALUES
  (1, 2, 1299.99, 1), (2, 5, 199.99, 1), (3, 1,  89.99, 2),
  (4, 10, 19.99, 2), (5, 3,  59.99, 1), (6, 7,  35.99, NULL),
  (7, 2,  42.99, 3), (8, 4,  34.99, 3), (9, 6,  24.99, 1),
  (10, 2, 119.99, 2),(1, 1, 1299.99, 3),(2, 3,  199.99, 1);

COUNT — Contar filas

-- Contar TODAS las filas (incluyendo NULLs en otras columnas)
SELECT COUNT(*) AS total_ventas FROM ventas;

-- Contar valores no-NULL en una columna específica
SELECT COUNT(vendedor_id) AS ventas_con_vendedor FROM ventas;
-- Omite las filas donde vendedor_id es NULL

-- COUNT DISTINCT: contar valores únicos
SELECT COUNT(DISTINCT producto_id) AS productos_distintos_vendidos FROM ventas;
SELECT COUNT(DISTINCT vendedor_id) AS vendedores_activos FROM ventas;

-- Contar con filtro (veremos FILTER más adelante)
SELECT
  COUNT(*) AS total,
  COUNT(*) FILTER (WHERE vendedor_id IS NOT NULL) AS con_vendedor,
  COUNT(*) FILTER (WHERE vendedor_id IS NULL) AS sin_vendedor
FROM ventas;

SUM — Sumar valores

-- Total de unidades vendidas
SELECT SUM(cantidad) AS total_unidades FROM ventas;

-- Total de ingresos (precio × cantidad)
SELECT SUM(cantidad * precio_unit) AS total_ingresos FROM ventas;

-- SUM ignora NULLs (si vendedor_id tuviera comisión, NULL no cuenta)
-- Para reemplazar NULL con 0 antes de sumar, usa COALESCE
SELECT SUM(COALESCE(vendedor_id, 0)) FROM ventas;  -- No muy útil aquí, solo ilustrativo

AVG — Promedio

-- Precio promedio de venta
SELECT AVG(precio_unit) AS precio_promedio FROM ventas;
SELECT ROUND(AVG(precio_unit), 2) AS precio_promedio FROM ventas;

-- Promedio de unidades por transacción
SELECT ROUND(AVG(cantidad), 1) AS promedio_unidades FROM ventas;

MIN y MAX — Mínimo y máximo

-- Rango de precios en las ventas
SELECT
  MIN(precio_unit) AS precio_minimo,
  MAX(precio_unit) AS precio_maximo,
  MAX(precio_unit) - MIN(precio_unit) AS diferencia
FROM ventas;

-- Cuándo fue la primera y última venta
SELECT
  MIN(vendido_en) AS primera_venta,
  MAX(vendido_en) AS ultima_venta
FROM ventas;

GROUP BY — Agrupar resultados

GROUP BY divide las filas en grupos y aplica la función de agregación a cada grupo por separado:

-- Ventas por producto
SELECT
  producto_id,
  COUNT(*)                        AS num_transacciones,
  SUM(cantidad)                   AS total_unidades,
  SUM(cantidad * precio_unit)     AS ingresos_totales,
  ROUND(AVG(precio_unit), 2)      AS precio_promedio
FROM ventas
GROUP BY producto_id
ORDER BY ingresos_totales DESC;

-- Ventas por vendedor (incluyendo NULL como "sin vendedor")
SELECT
  COALESCE(vendedor_id::TEXT, 'Sin vendedor') AS vendedor,
  COUNT(*)                    AS num_ventas,
  SUM(cantidad * precio_unit) AS total_vendido
FROM ventas
GROUP BY vendedor_id
ORDER BY total_vendido DESC NULLS LAST;

[!warning] Regla de ORO de GROUP BY: toda columna en el SELECT que NO sea una función de agregación DEBE aparecer en el GROUP BY. PostgreSQL lanzará un error si violas esta regla. Si quieres mostrar el nombre del vendedor junto con las estadísticas, necesitas hacer un JOIN con la tabla de vendedores, o usar una subconsulta.

GROUP BY con múltiples columnas

-- Agrupar por producto Y vendedor
SELECT
  producto_id,
  vendedor_id,
  SUM(cantidad * precio_unit) AS ingresos
FROM ventas
GROUP BY producto_id, vendedor_id
ORDER BY producto_id, ingresos DESC;

HAVING — Filtrar grupos

WHERE filtra filas individuales antes de agrupar. HAVING filtra grupos después de agrupar:

-- Solo productos con más de 2 transacciones
SELECT
  producto_id,
  COUNT(*) AS num_transacciones,
  SUM(cantidad * precio_unit) AS ingresos
FROM ventas
GROUP BY producto_id
HAVING COUNT(*) > 2
ORDER BY ingresos DESC;

-- Vendedores con más de 500€ en ventas totales
SELECT
  vendedor_id,
  SUM(cantidad * precio_unit) AS total
FROM ventas
WHERE vendedor_id IS NOT NULL  -- WHERE filtra ANTES de agrupar
GROUP BY vendedor_id
HAVING SUM(cantidad * precio_unit) > 500  -- HAVING filtra DESPUÉS de agrupar
ORDER BY total DESC;

-- Combinando WHERE y HAVING
SELECT
  producto_id,
  SUM(cantidad) AS unidades
FROM ventas
WHERE vendido_en >= NOW() - INTERVAL '30 days'  -- Solo ventas del último mes
GROUP BY producto_id
HAVING SUM(cantidad) >= 3                        -- Con al menos 3 unidades
ORDER BY unidades DESC;

FILTER — Agregación condicional

FILTER permite calcular múltiples agregaciones con diferentes condiciones en una sola consulta:

-- Dashboard de ventas por período en una sola consulta
SELECT
  COUNT(*)                                            AS total_ventas,
  COUNT(*) FILTER (WHERE vendido_en >= CURRENT_DATE - 7)  AS ventas_ultima_semana,
  SUM(cantidad * precio_unit)                         AS ingresos_totales,
  SUM(cantidad * precio_unit)
    FILTER (WHERE vendedor_id IS NOT NULL)             AS ingresos_con_vendedor,
  ROUND(AVG(precio_unit), 2)                          AS precio_promedio,
  ROUND(AVG(precio_unit) FILTER (WHERE precio_unit > 100), 2) AS precio_promedio_premium
FROM ventas;

Funciones de texto

PostgreSQL ofrece decenas de funciones para manipular cadenas de texto:

-- Funciones básicas de texto
SELECT
  UPPER('hola mundo')           AS mayusculas,       -- 'HOLA MUNDO'
  LOWER('HOLA MUNDO')           AS minusculas,       -- 'hola mundo'
  LENGTH('PostgreSQL')          AS longitud,         -- 10
  TRIM('  espacios  ')          AS sin_espacios,     -- 'espacios'
  LTRIM('  izquierda')          AS sin_izq,          -- 'izquierda'
  RTRIM('derecha  ')            AS sin_der,          -- 'derecha'
  LPAD('42', 5, '0')            AS relleno_izq,      -- '00042'
  RPAD('SQL', 6, '.')           AS relleno_der,      -- 'SQL...'
  SUBSTRING('PostgreSQL', 1, 4) AS subcadena,        -- 'Post'
  REPLACE('hola mundo', 'mundo', 'SQL') AS reemplazo,-- 'hola SQL'
  SPLIT_PART('a,b,c', ',', 2)   AS parte,            -- 'b'
  CONCAT('Hola', ' ', 'mundo')  AS concatenar,       -- 'Hola mundo'
  'Hola' || ' ' || 'mundo'      AS concat_operador;  -- 'Hola mundo'

-- Búsqueda y posición
SELECT
  POSITION('SQL' IN 'PostgreSQL')   AS posicion,     -- 8
  STRPOS('PostgreSQL', 'gre')       AS posicion2,    -- 4
  STARTS_WITH('PostgreSQL', 'Post') AS comienza_con; -- true

-- Ejemplo práctico: formatear nombres de productos
SELECT
  nombre,
  INITCAP(LOWER(nombre)) AS nombre_formateado,  -- Capitaliza cada palabra
  LEFT(nombre, 20) || CASE WHEN LENGTH(nombre) > 20 THEN '...' ELSE '' END AS nombre_corto
FROM productos;

Funciones de fecha y hora

-- Funciones de fecha actuales
SELECT
  NOW()                     AS ahora_con_tz,
  CURRENT_TIMESTAMP         AS timestamp_actual,
  CURRENT_DATE              AS solo_fecha,
  CURRENT_TIME              AS solo_hora;

-- Extracción de partes de una fecha
SELECT
  vendido_en,
  EXTRACT(YEAR  FROM vendido_en) AS año,
  EXTRACT(MONTH FROM vendido_en) AS mes,
  EXTRACT(DOW   FROM vendido_en) AS dia_semana,  -- 0=domingo, 6=sábado
  DATE_PART('hour', vendido_en)  AS hora,
  DATE_TRUNC('month', vendido_en) AS inicio_mes,
  DATE_TRUNC('week',  vendido_en) AS inicio_semana
FROM ventas;

-- Aritmética de fechas
SELECT
  NOW() + INTERVAL '7 days'      AS en_una_semana,
  NOW() - INTERVAL '1 month'     AS hace_un_mes,
  NOW()::DATE - '2024-01-01'     AS dias_desde_inicio_año,
  AGE(NOW(), '1990-05-15')       AS edad_desde_fecha;

-- Ventas agrupadas por mes
SELECT
  DATE_TRUNC('month', vendido_en) AS mes,
  COUNT(*) AS num_ventas,
  SUM(cantidad * precio_unit) AS ingresos
FROM ventas
GROUP BY DATE_TRUNC('month', vendido_en)
ORDER BY mes DESC;

COALESCE — Manejar valores NULL

COALESCE devuelve el primer valor no-NULL de su lista de argumentos:

-- Reemplazar NULL con un valor por defecto
SELECT
  nombre,
  COALESCE(descripcion, 'Sin descripción') AS descripcion,
  COALESCE(descripcion, nombre)            AS desc_o_nombre  -- Usa nombre si no hay desc
FROM productos;

-- Muy útil en cálculos donde NULL propagaría el error
SELECT
  vendedor_id,
  COALESCE(vendedor_id, 0) + 100 AS id_ajustado
FROM ventas;

-- En agregaciones con valores opcionales
SELECT
  SUM(COALESCE(descuento, 0)) AS total_descuentos
FROM pedidos;

NULLIF — Evitar división por cero

NULLIF(a, b) devuelve NULL si a = b, de lo contrario devuelve a. El uso más común es evitar divisiones por cero:

-- Sin NULLIF: error si total_ventas = 0
-- SELECT ingresos / total_ventas FROM resumen;

-- Con NULLIF: devuelve NULL en lugar de error
SELECT
  producto_id,
  SUM(cantidad * precio_unit) AS ingresos,
  COUNT(*) AS ventas,
  SUM(cantidad * precio_unit) / NULLIF(COUNT(*), 0) AS promedio_por_venta
FROM ventas
GROUP BY producto_id;

CASE WHEN — Lógica condicional

CASE WHEN es el equivalente SQL de if-else y permite crear columnas calculadas con lógica condicional:

-- CASE simple (comparación de igualdad)
SELECT
  nombre,
  precio,
  CASE categoria_id
    WHEN 1 THEN 'Electrónica'
    WHEN 2 THEN 'Ropa'
    WHEN 3 THEN 'Libros'
    ELSE 'Otra'
  END AS categoria_nombre
FROM productos;

-- CASE buscado (condiciones arbitrarias)
SELECT
  nombre,
  precio,
  CASE
    WHEN precio < 30  THEN 'Económico'
    WHEN precio < 100 THEN 'Rango medio'
    WHEN precio < 500 THEN 'Premium'
    ELSE 'Lujo'
  END AS segmento_precio,
  CASE
    WHEN stock = 0              THEN 'Agotado'
    WHEN stock < 10             THEN 'Stock bajo'
    WHEN stock BETWEEN 10 AND 50 THEN 'Stock normal'
    ELSE 'Stock alto'
  END AS estado_stock
FROM productos
ORDER BY precio DESC;

-- CASE en agregaciones: pivot manual
SELECT
  DATE_TRUNC('month', vendido_en)             AS mes,
  SUM(CASE WHEN vendedor_id = 1 THEN cantidad * precio_unit ELSE 0 END) AS vendedor_1,
  SUM(CASE WHEN vendedor_id = 2 THEN cantidad * precio_unit ELSE 0 END) AS vendedor_2,
  SUM(CASE WHEN vendedor_id = 3 THEN cantidad * precio_unit ELSE 0 END) AS vendedor_3,
  SUM(CASE WHEN vendedor_id IS NULL THEN cantidad * precio_unit ELSE 0 END) AS sin_vendedor,
  SUM(cantidad * precio_unit)                 AS total
FROM ventas
GROUP BY DATE_TRUNC('month', vendido_en)
ORDER BY mes;

[!tip] CASE WHEN en GROUP BY: puedes usar expresiones CASE dentro de GROUP BY para crear categorías dinámicas. Por ejemplo, GROUP BY CASE WHEN precio < 100 THEN 'bajo' ELSE 'alto' END agrupa los productos en dos segmentos sin necesidad de tener esa columna en la tabla.

Consulta analítica completa

Combinando todo lo aprendido, aquí un reporte de ventas completo:

-- Reporte ejecutivo de ventas por producto
SELECT
  p.id                                        AS producto_id,
  p.nombre                                    AS producto,
  c.nombre                                    AS categoria,
  COUNT(v.id)                                 AS num_transacciones,
  COALESCE(SUM(v.cantidad), 0)                AS total_unidades,
  COALESCE(ROUND(SUM(v.cantidad * v.precio_unit), 2), 0) AS ingresos_totales,
  COALESCE(ROUND(AVG(v.precio_unit), 2), 0)  AS precio_promedio,
  CASE
    WHEN COALESCE(SUM(v.cantidad * v.precio_unit), 0) = 0 THEN 'Sin ventas'
    WHEN SUM(v.cantidad * v.precio_unit) < 100  THEN 'Bajo'
    WHEN SUM(v.cantidad * v.precio_unit) < 1000 THEN 'Medio'
    ELSE 'Alto'
  END                                         AS nivel_ventas
FROM productos p
LEFT JOIN categorias c ON p.categoria_id = c.id
LEFT JOIN ventas v ON p.id = v.producto_id
GROUP BY p.id, p.nombre, c.nombre
HAVING COALESCE(SUM(v.cantidad * v.precio_unit), 0) > 0
ORDER BY ingresos_totales DESC;

Las funciones de agregación junto con GROUP BY y HAVING son la base del análisis de datos en SQL. En la siguiente lección aprenderemos los JOINs, que nos permiten combinar datos de múltiples tablas en una sola consulta.


nextSteps:

  • slug: joins-completo label: "Siguiente: JOINs completo"