Tabla dinámica en SQL: pasar filas a columnas con CASE y FILTER

Las consultas con GROUP BY devuelven una fila por cada combinación: categoría y trimestre, categoría y trimestre… Para leerlo como una hoja de cálculo hace falta una tabla dinámica: una fila por categoría y una columna por trimestre. SQL estándar no tiene una orden PIVOT, pero se consigue con una suma condicional.

Todas las consultas usan la base de ejemplo «tienda» del curso (clientes, productos, pedidos y sus líneas). Las que funcionan en SQLite tienen un enlace para abrirlas en el editor de SQL, que la trae cargada.

El punto de partida: formato largo

Ventas por categoría y trimestre de 2026, sin pedidos cancelados ni devueltos. En SQLite, el trimestre sale del mes: (mes + 2) / 3, que con división entera da 1 para enero a marzo, 2 para abril a junio, etc. El LEFT JOIN y el COALESCE mantienen el producto que no tiene categoría.

SELECT COALESCE(c.nombre, 'Sin categoría') AS categoria,
       (CAST(strftime('%m', pe.fecha) AS INTEGER) + 2) / 3 AS trimestre,
       ROUND(SUM(d.cantidad * d.precio_unitario), 2) AS ventas
FROM detalle d
JOIN pedidos pe       ON pe.id = d.pedido_id
JOIN productos p      ON p.id = d.producto_id
LEFT JOIN categorias c ON c.id = p.categoria_id
WHERE pe.estado NOT IN ('cancelado', 'devuelto')
GROUP BY categoria, trimestre
ORDER BY categoria, trimestre;

Ábrelo en el editor y cámbialo →

resultado · SQLitecategoria      | trimestre | ventas
---------------+-----------+--------
Almacenamiento |         1 |   233.3
Almacenamiento |         2 |   646.6
Almacenamiento |         3 |   101.5
Almacenamiento |         4 |    94.9
Monitores      |         1 |  1878.6
Monitores      |         2 |   747.9
Monitores      |         3 |   906.9
Periféricos    |         1 |  343.47
Periféricos    |         2 |  167.98
Periféricos    |         3 |  253.49
Periféricos    |         4 |  173.49
Portátiles     |         1 | 5748.97
Portátiles     |         3 | 3048.99
Redes          |         1 |   328.8
Redes          |         2 |    39.9
Redes          |         3 |   288.9
Redes          |         4 |    39.9
Sin categoría  |         2 |    29.0

Una columna por trimestre con SUM(CASE WHEN)

La idea: para la columna t1, se suma el importe si la fila es del trimestre 1 y 0 si no. Una expresión así por cada columna, agrupando solo por categoría:

WITH ventas AS (
  SELECT COALESCE(c.nombre, 'Sin categoría') AS categoria,
         (CAST(strftime('%m', pe.fecha) AS INTEGER) + 2) / 3 AS trimestre,
         d.cantidad * d.precio_unitario AS importe
  FROM detalle d
  JOIN pedidos pe       ON pe.id = d.pedido_id
  JOIN productos p      ON p.id = d.producto_id
  LEFT JOIN categorias c ON c.id = p.categoria_id
  WHERE pe.estado NOT IN ('cancelado', 'devuelto')
)
SELECT categoria,
       ROUND(SUM(CASE WHEN trimestre = 1 THEN importe ELSE 0 END), 2) AS t1,
       ROUND(SUM(CASE WHEN trimestre = 2 THEN importe ELSE 0 END), 2) AS t2,
       ROUND(SUM(CASE WHEN trimestre = 3 THEN importe ELSE 0 END), 2) AS t3,
       ROUND(SUM(CASE WHEN trimestre = 4 THEN importe ELSE 0 END), 2) AS t4,
       ROUND(SUM(importe), 2) AS total
FROM ventas
GROUP BY categoria
ORDER BY total DESC;

Ábrelo en el editor y cámbialo →

resultado · SQLitecategoria      | t1      | t2     | t3      | t4     | total
---------------+---------+--------+---------+--------+--------
Portátiles     | 5748.97 |    0.0 | 3048.99 |    0.0 | 8797.96
Monitores      |  1878.6 |  747.9 |   906.9 |    0.0 |  3533.4
Almacenamiento |   233.3 |  646.6 |   101.5 |   94.9 |  1076.3
Periféricos    |  343.47 | 167.98 |  253.49 | 173.49 |  938.43
Redes          |   328.8 |   39.9 |   288.9 |   39.9 |   697.5
Sin categoría  |     0.0 |   29.0 |     0.0 |    0.0 |    29.0

Tabla dinámica: las filas por trimestre se convierten en columnas t1 a t4De una fila por categoría y trimestre a una columna por trimestrecategoriatrim.ventasMonitores11878.60Monitores2747.90Monitores3906.90Redes1328.80Redes239.90Redes3288.90Redes439.90SUM(CASEWHEN …)categoriat1t2t3t4Monitores1878.60747.90906.900.00Redes328.8039.90288.9039.90Un hueco (Monitores en t4) sale 0 con CASEy NULL con FILTER

Cada SUM(CASE WHEN trimestre = n …) recoge en una columna las filas de su trimestre. Esquema: decodigo.com.

Esta forma funciona en cualquier motor. En MySQL, el trimestre se saca con QUARTER(fecha); el resto de la consulta es idéntico:

WITH ventas AS (
  SELECT COALESCE(c.nombre, 'Sin categoría') AS categoria,
         QUARTER(pe.fecha) AS trimestre,
         d.cantidad * d.precio_unitario AS importe
  FROM detalle d
  JOIN pedidos pe       ON pe.id = d.pedido_id
  JOIN productos p      ON p.id = d.producto_id
  LEFT JOIN categorias c ON c.id = p.categoria_id
  WHERE pe.estado NOT IN ('cancelado', 'devuelto')
)
SELECT categoria,
       SUM(CASE WHEN trimestre = 1 THEN importe ELSE 0 END) AS t1,
       SUM(CASE WHEN trimestre = 2 THEN importe ELSE 0 END) AS t2,
       SUM(CASE WHEN trimestre = 3 THEN importe ELSE 0 END) AS t3,
       SUM(CASE WHEN trimestre = 4 THEN importe ELSE 0 END) AS t4,
       SUM(importe) AS total
FROM ventas
GROUP BY categoria
ORDER BY total DESC;
resultado · MySQL+----------------+---------+--------+---------+--------+---------+
| categoria      | t1      | t2     | t3      | t4     | total   |
+----------------+---------+--------+---------+--------+---------+
| Portátiles     | 5748.97 |   0.00 | 3048.99 |   0.00 | 8797.96 |
| Monitores      | 1878.60 | 747.90 |  906.90 |   0.00 | 3533.40 |
| Almacenamiento |  233.30 | 646.60 |  101.50 |  94.90 | 1076.30 |
| Periféricos    |  343.47 | 167.98 |  253.49 | 173.49 |  938.43 |
| Redes          |  328.80 |  39.90 |  288.90 |  39.90 |  697.50 |
| Sin categoría  |    0.00 |  29.00 |    0.00 |   0.00 |   29.00 |
+----------------+---------+--------+---------+--------+---------+

Con FILTER: más corto y con NULL en los huecos

El estándar SQL tiene una forma más legible: SUM(importe) FILTER (WHERE trimestre = 1). La admiten SQLite (desde la 3.30) y PostgreSQL. Hay una diferencia: un trimestre sin ventas da NULL en vez de 0, porque no hay nada que sumar.

-- La misma tabla con FILTER (SQL estándar; SQLite lo admite desde la 3.30)
WITH ventas AS (
  SELECT COALESCE(c.nombre, 'Sin categoría') AS categoria,
         (CAST(strftime('%m', pe.fecha) AS INTEGER) + 2) / 3 AS trimestre,
         d.cantidad * d.precio_unitario AS importe
  FROM detalle d
  JOIN pedidos pe       ON pe.id = d.pedido_id
  JOIN productos p      ON p.id = d.producto_id
  LEFT JOIN categorias c ON c.id = p.categoria_id
  WHERE pe.estado NOT IN ('cancelado', 'devuelto')
)
SELECT categoria,
       ROUND(SUM(importe) FILTER (WHERE trimestre = 1), 2) AS t1,
       ROUND(SUM(importe) FILTER (WHERE trimestre = 2), 2) AS t2,
       ROUND(SUM(importe) FILTER (WHERE trimestre = 3), 2) AS t3,
       ROUND(SUM(importe) FILTER (WHERE trimestre = 4), 2) AS t4
FROM ventas
GROUP BY categoria
ORDER BY categoria;

Ábrelo en el editor y cámbialo →

resultado · SQLitecategoria      | t1      | t2     | t3      | t4
---------------+---------+--------+---------+-------
Almacenamiento |   233.3 |  646.6 |   101.5 |   94.9
Monitores      |  1878.6 |  747.9 |   906.9 |   NULL
Periféricos    |  343.47 | 167.98 |  253.49 | 173.49
Portátiles     | 5748.97 |   NULL | 3048.99 |   NULL
Redes          |   328.8 |   39.9 |   288.9 |   39.9
Sin categoría  |    NULL |   29.0 |    NULL |   NULL

En PostgreSQL, el trimestre se saca con EXTRACT(QUARTER FROM fecha), y como los precios son NUMERIC, las sumas salen con sus dos decimales sin necesidad de ROUND:

WITH ventas AS (
  SELECT COALESCE(c.nombre, 'Sin categoría') AS categoria,
         EXTRACT(QUARTER FROM pe.fecha) AS trimestre,
         d.cantidad * d.precio_unitario AS importe
  FROM detalle d
  JOIN pedidos pe       ON pe.id = d.pedido_id
  JOIN productos p      ON p.id = d.producto_id
  LEFT JOIN categorias c ON c.id = p.categoria_id
  WHERE pe.estado NOT IN ('cancelado', 'devuelto')
)
SELECT categoria,
       SUM(importe) FILTER (WHERE trimestre = 1) AS t1,
       SUM(importe) FILTER (WHERE trimestre = 2) AS t2,
       SUM(importe) FILTER (WHERE trimestre = 3) AS t3,
       SUM(importe) FILTER (WHERE trimestre = 4) AS t4,
       SUM(importe) AS total
FROM ventas
GROUP BY categoria
ORDER BY total DESC;
resultado · PostgreSQL   categoria    |   t1    |   t2   |   t3    |   t4   |  total  
----------------+---------+--------+---------+--------+---------
 Portátiles     | 5748.97 |        | 3048.99 |        | 8797.96
 Monitores      | 1878.60 | 747.90 |  906.90 |        | 3533.40
 Almacenamiento |  233.30 | 646.60 |  101.50 |  94.90 | 1076.30
 Periféricos    |  343.47 | 167.98 |  253.49 | 173.49 |  938.43
 Redes          |  328.80 |  39.90 |  288.90 |  39.90 |  697.50
 Sin categoría  |         |  29.00 |         |        |   29.00

MySQL 8.4 no tiene FILTER:

SELECT SUM(precio) FILTER (WHERE stock > 0) AS con_stock FROM productos;
resultado · MySQLERROR 1064 (42000) at line 1: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '(WHERE stock > 0) AS con_stock FROM productos' at line 1
SQLitePostgreSQLMySQL 8.4
SUM(CASE WHEN …)SíSíSí
FILTER (WHERE …)Sí (3.30+)SíNo
Trimestre de una fechastrftime('%m', fecha) y cuentaEXTRACT(QUARTER FROM fecha)QUARTER(fecha)
¿Y si no sé cuántas columnas habrá? SQL necesita conocer las columnas al escribir la consulta. Si los valores cambian (un año nuevo, una categoría nueva), lo normal es generar la consulta desde el programa o hacer la tabla dinámica en la hoja de cálculo o en el código, a partir del formato largo.

Todas las consultas de esta página se ejecutaron antes de publicarla sobre la base de ejemplo «tienda» del curso, en contenedores limpios: SQLite 3.46.1 (el de Python 3.13), PostgreSQL 17 y MySQL 8.4. Los resultados y los mensajes de error están copiados de esa ejecución. Documentación oficial: FILTER en las funciones de agregado de PostgreSQL.