SQL · Guías · 30 de octubre de 2026 · 13 min de lectura
PIVOT en SQL: convertir filas en columnas (y UNPIVOT), con ejemplos

Hacer un pivot en SQL es convertir los valores de una columna en columnas separadas, una por valor: lo mismo que hace una tabla dinámica de Excel o Google Sheets cuando arrastras un campo a “Columnas”. La forma portable es la agregación condicional: GROUP BY por la etiqueta de fila y un agregado por cada columna de salida, que cuenta o suma solo su propio valor, SUM(CASE WHEN month = 7 THEN 1 ELSE 0 END) AS jul. Funciona en cualquier base de datos. Algunos motores añaden un operador PIVOT que escribe esas columnas por ti (DuckDB, SQL Server, Oracle, Snowflake, BigQuery); PostgreSQL tiene en su lugar la función crosstab; MySQL no tiene ninguna de las dos. La operación inversa, unpivot, devuelve las columnas a filas.
Todos los ejemplos usan la base de datos de una aerolínea del curso de SQL de Nomto, con sintaxis de PostgreSQL sobre DuckDB (el motor de la sala de SQL de Nomto). Los valores están en inglés y cada tabla de resultados es la salida real de la consulta. Los fragmentos de SQL Server y de crosstab de PostgreSQL están marcados y no se han ejecutado aquí.
Datos en formato largo y en formato ancho
Un GROUP BY por dos columnas devuelve datos en formato largo: una fila por combinación. Vuelos por modelo de avión y mes:
SELECT a.model,
date_trunc('month', f.scheduled_departure)::date AS month,
COUNT(*) AS flights
FROM flights f
JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
GROUP BY a.model, month
ORDER BY a.model, month
LIMIT 6;
| model | month | flights |
|---|---|---|
| Airbus A319-100 | 2026-07-01 | 401 |
| Airbus A319-100 | 2026-08-01 | 637 |
| Airbus A319-100 | 2026-09-01 | 201 |
| Airbus A321-200 | 2026-07-01 | 640 |
| Airbus A321-200 | 2026-08-01 | 992 |
| Airbus A321-200 | 2026-09-01 | 320 |
Son las seis primeras de 24 filas. El formato largo es el que mejor les sienta a las bases de datos, a los gráficos y al SQL que venga después. Quien lee un informe suele querer la versión ancha: una fila por modelo y una columna por mes. Llegar a ella es el pivot.
Pivot con CASE: la forma portable
Agrupa solo por la etiqueta de fila y construye cada columna con un agregado que mire un único mes:
SELECT a.model,
SUM(CASE WHEN EXTRACT(month FROM f.scheduled_departure) = 7 THEN 1 ELSE 0 END) AS jul,
SUM(CASE WHEN EXTRACT(month FROM f.scheduled_departure) = 8 THEN 1 ELSE 0 END) AS aug,
SUM(CASE WHEN EXTRACT(month FROM f.scheduled_departure) = 9 THEN 1 ELSE 0 END) AS sep,
COUNT(*) AS total
FROM flights f
JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
GROUP BY a.model
ORDER BY total DESC;
| model | jul | aug | sep | total |
|---|---|---|---|---|
| Cessna 208 Caravan | 3035 | 4714 | 1524 | 9273 |
| Bombardier CRJ-200 | 2953 | 4605 | 1490 | 9048 |
| Sukhoi Superjet-100 | 2782 | 4326 | 1396 | 8504 |
| Airbus A321-200 | 640 | 992 | 320 | 1952 |
| Boeing 737-300 | 416 | 648 | 210 | 1274 |
| Airbus A319-100 | 401 | 637 | 201 | 1239 |
| Boeing 767-300 | 399 | 622 | 200 | 1221 |
| Boeing 777-300 | 200 | 310 | 100 | 610 |
(El calendario va del 12 de julio al 10 de septiembre, así que julio y septiembre son meses incompletos.)
Cómo funciona: cada fila de vuelo pasa por las tres expresiones CASE, y cada una suma 1 solo si la fila pertenece a su mes. Después, GROUP BY a.model reduce las filas a una por modelo. El total por fila sale gratis: COUNT(*) en el mismo SELECT. El noveno modelo, el Airbus A320-200, no tiene ningún vuelo, así que el inner join lo deja fuera; parte de aircrafts con un LEFT JOIN si quieres que aparezcan las filas vacías.
Esto funciona sin cambios en PostgreSQL, MySQL, SQL Server, Oracle, SQLite y DuckDB (solo cambia la función para extraer el mes: MONTH() en MySQL y SQL Server). Para sumar un importe en lugar de contar, devuelve el valor en la rama THEN: SUM(CASE WHEN ... THEN amount ELSE 0 END). La guía de CASE WHEN en SQL detalla las diferencias entre SUM y COUNT en la agregación condicional.
FILTER: el mismo pivot, más corto
PostgreSQL, DuckDB y SQLite admiten la cláusula estándar FILTER, que se lee mejor cuando hay muchas columnas. Como un pivot pone números relacionados uno al lado del otro, en el paso siguiente puedes calcular ratios entre las columnas nuevas. Eventos de la app por plataforma, con tasas de conversión:
WITH p AS (
SELECT platform,
COUNT(*) FILTER (WHERE event_name = 'search') AS searches,
COUNT(*) FILTER (WHERE event_name = 'checkout_start') AS checkouts,
COUNT(*) FILTER (WHERE event_name = 'purchase') AS purchases
FROM app_events
GROUP BY platform
)
SELECT platform, searches, checkouts, purchases,
ROUND(100.0 * purchases / searches, 1) AS purchases_per_100_searches,
ROUND(100.0 * purchases / checkouts, 1) AS checkout_to_purchase_pct
FROM p
ORDER BY platform;
| platform | searches | checkouts | purchases | purchases_per_100_searches | checkout_to_purchase_pct |
|---|---|---|---|---|---|
| android | 112610 | 39061 | 21052 | 18.7 | 53.9 |
| ios | 76957 | 26686 | 14262 | 18.5 | 53.4 |
| web | 65626 | 28895 | 16538 | 25.2 | 57.2 |
La web convierte mejor en los dos pasos. En formato largo, la misma comparación necesitaría un self join o funciones de ventana; después del pivot es aritmética simple entre columnas. (Son recuentos de eventos en bruto; la tabla incluye también eventos reenviados, que se eliminan como explica la guía para encontrar duplicados en SQL.)
MySQL, SQL Server y Oracle no tienen FILTER; ahí usa SUM(CASE ...).
Pivotar valores, no recuentos: MAX(CASE …)
Cuando cada celda contiene exactamente un valor, como un atributo guardado en una fila, usa MAX (o MIN) para tomarlo. Tarifas vigentes desde Sheremétievo, una columna por clase:
SELECT departure_airport, arrival_airport,
MAX(CASE WHEN fare_conditions = 'Economy' THEN price END) AS economy,
MAX(CASE WHEN fare_conditions = 'Comfort' THEN price END) AS comfort,
MAX(CASE WHEN fare_conditions = 'Business' THEN price END) AS business
FROM fare_price_history
WHERE valid_to IS NULL
AND departure_airport = 'SVO'
GROUP BY departure_airport, arrival_airport
ORDER BY business DESC NULLS LAST
LIMIT 5;
| departure_airport | arrival_airport | economy | comfort | business |
|---|---|---|---|---|
| SVO | UUS | 693.00 | null | 2081.00 |
| SVO | DYR | 618.00 | null | 1853.00 |
| SVO | MJZ | 415.00 | null | 1246.00 |
| SVO | KJA | 333.00 | null | 998.00 |
| SVO | RGK | 313.00 | null | 940.00 |
Aquí el CASE no tiene ELSE, así que una clase que falta sale como NULL, y es lo correcto: estas rutas no tienen tarifa Comfort (en toda la tabla solo la tienen 10 rutas), y un precio de 0 sería mentira. Con recuentos es al revés: 0 es la respuesta correcta para “ningún vuelo”, y ELSE 0 o COUNT la dan. MAX funciona también con texto y fechas, y por eso es la forma habitual de convertir filas clave-valor (nombre del atributo, valor del atributo) en columnas.
El operador PIVOT en DuckDB
DuckDB tiene una sentencia PIVOT. ON indica la columna cuyos valores se convierten en columnas, USING el agregado y GROUP BY la etiqueta de fila. Con una lista IN explícita eliges las columnas y sus nombres:
PIVOT (
SELECT a.model, EXTRACT(month FROM f.scheduled_departure) AS m
FROM flights f
JOIN aircrafts a USING (aircraft_code)
)
ON m IN (7 AS jul, 8 AS aug, 9 AS sep)
USING COUNT(*)
GROUP BY model
ORDER BY model;
| model | jul | aug | sep |
|---|---|---|---|
| Airbus A319-100 | 401 | 637 | 201 |
| Airbus A321-200 | 640 | 992 | 320 |
| Boeing 737-300 | 416 | 648 | 210 |
| Boeing 767-300 | 399 | 622 | 200 |
| Boeing 777-300 | 200 | 310 | 100 |
| Bombardier CRJ-200 | 2953 | 4605 | 1490 |
| Cessna 208 Caravan | 3035 | 4714 | 1524 |
| Sukhoi Superjet-100 | 2782 | 4326 | 1396 |
Los mismos números que la versión con CASE. Dos extras hacen cómodo el PIVOT de DuckDB. Varios agregados a la vez, cada uno con una columna por valor:
PIVOT (
SELECT f.departure_airport, tf.fare_conditions, tf.amount
FROM ticket_flights tf
JOIN flights f USING (flight_id)
WHERE f.departure_airport IN ('SVO', 'DME', 'VKO')
)
ON fare_conditions IN ('Economy', 'Business')
USING COUNT(*) AS seg, ROUND(SUM(amount)) AS rev
GROUP BY departure_airport
ORDER BY departure_airport;
| departure_airport | Economy_seg | Economy_rev | Business_seg | Business_rev |
|---|---|---|---|---|
| DME | 126345 | 24288385 | 16183 | 9704569 |
| SVO | 133730 | 20813481 | 15959 | 7549309 |
| VKO | 53588 | 7078523 | 6035 | 2300182 |
Y, sin IN, encuentra las columnas en los propios datos:
PIVOT flights
ON status
USING COUNT(*)
GROUP BY departure_airport
ORDER BY departure_airport
LIMIT 3;
| departure_airport | Arrived | Cancelled | Delayed | Departed | On Time | Scheduled |
|---|---|---|---|---|---|---|
| AAQ | 67 | 1 | 0 | 0 | 2 | 61 |
| ABA | 119 | 5 | 1 | 0 | 4 | 106 |
| AER | 292 | 6 | 1 | 2 | 10 | 273 |
Es práctico para explorar, pero fíjate en la columna On Time, con un espacio en el nombre, y en que el conjunto de columnas depende ahora de los datos. Más sobre esto abajo.
PIVOT en SQL Server (y Oracle)
SQL Server es uno de los motores más extendidos en empresas de España y Latinoamérica, así que conviene conocer su PIVOT. Es una cláusula dentro de FROM. No se ha ejecutado aquí; la forma es esta:
-- SQL Server
SELECT model, [7] AS jul, [8] AS aug, [9] AS sep
FROM (
SELECT a.model, MONTH(f.scheduled_departure) AS m, f.flight_id
FROM flights f
JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
) AS src
PIVOT (
COUNT(flight_id) FOR m IN ([7], [8], [9])
) AS p;
La trampa: no hay GROUP BY. SQL Server agrupa por todas las columnas del origen que no se usan en el pivot. Por eso el origen es una subconsulta con solo model, m y flight_id; si ahí seleccionas f.*, obtienes una fila por vuelo en lugar de una por modelo. La lista IN lleva nombres de columna entre corchetes, y solo se permite un agregado. El PIVOT de Oracle se parece, pero acepta varios agregados y literales entre comillas en IN.
PostgreSQL: crosstab
PostgreSQL no tiene PIVOT. La extensión tablefunc ofrece crosstab, que recibe una consulta que devuelve (etiqueta de fila, categoría, valor) y la despliega. No se ha ejecutado aquí:
-- PostgreSQL, needs: CREATE EXTENSION tablefunc;
SELECT *
FROM crosstab(
$$SELECT a.model::text, EXTRACT(month FROM f.scheduled_departure)::int, COUNT(*)
FROM flights f
JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
GROUP BY 1, 2
ORDER BY 1, 2$$,
$$VALUES (7), (8), (9)$$
) AS ct(model text, jul bigint, aug bigint, sep bigint);
Sigues teniendo que enumerar las columnas de salida y sus tipos en AS ct(...), la consulta de origen tiene que ir ordenada por la etiqueta de fila y las celdas vacías vuelven como NULL, no como 0. Usa siempre la forma de dos argumentos de este ejemplo: la de un argumento rellena las columnas de izquierda a derecha y desplaza valores a la columna equivocada cuando a una fila le falta una categoría. La mayoría de usuarios de PostgreSQL encuentran más sencillo COUNT(*) FILTER (WHERE ...) y se quedan con eso.
Pivot dinámico: cuando no conoces las columnas
Una consulta SQL tiene que declarar sus columnas de salida antes de ejecutarse. Por eso todos los métodos de arriba enumeran los valores a mano (jul, aug, sep), y por eso “pivotar por todos los valores distintos, sean cuales sean” no se puede hacer con una consulta normal en la mayoría de bases de datos. Las opciones:
- Enumerar los valores. Lo mejor para informes: las columnas son estables, tienen nombre y van en un orden lógico. Si aparece un estado nuevo en los datos, se queda fuera sin avisar, así que añade una columna
other(COUNT(*) FILTER (WHERE status NOT IN (...))) cuando importe. - Generar el SQL. En SQL Server, construye la lista de columnas a partir de los datos con
STRING_AGGy ejecútala consp_executesql; en PostgreSQL, una función conEXECUTE; o monta la consulta en Python o en tu herramienta de BI. - Dejar que lo haga el motor. El
PIVOTde DuckDB sinIN, oPIVOT ... ANYen Snowflake. Cómodo, pero la forma del resultado cambia con los datos, y eso rompe el código posterior que espera columnas fijas. - No pivotar en SQL. Devuelve datos largos y pivota en la tabla dinámica de Excel o Google Sheets, en el objeto Matriz de Power BI o en pandas (
pivot_table). A menudo es el reparto de trabajo más limpio.
Unpivot: de columnas a filas
Hacer unpivot convierte una tabla ancha en una larga: cada pareja (fila, columna) pasa a ser una fila con una columna “nombre” y otra “valor”. Lo necesitas cuando los datos llegan como una hoja de cálculo con una columna por mes (el típico Excel de presupuesto con ene, feb, mar…), o para graficar una tabla ancha. Partimos de un mapa de asientos ancho, una columna por clase, y lo desplegamos en DuckDB:
WITH wide AS (
SELECT aircraft_code,
COUNT(*) FILTER (WHERE fare_conditions = 'Economy') AS economy,
COUNT(*) FILTER (WHERE fare_conditions = 'Comfort') AS comfort,
COUNT(*) FILTER (WHERE fare_conditions = 'Business') AS business
FROM seats
GROUP BY aircraft_code
)
UNPIVOT wide
ON economy, comfort, business
INTO NAME fare_class VALUE seats
ORDER BY aircraft_code, fare_class
LIMIT 6;
| aircraft_code | fare_class | seats |
|---|---|---|
| 319 | business | 20 |
| 319 | comfort | 0 |
| 319 | economy | 96 |
| 320 | business | 20 |
| 320 | comfort | 0 |
| 320 | economy | 120 |
La alternativa portable funciona en todas partes: un SELECT por columna, unidos con UNION ALL.
SELECT aircraft_code, 'economy' AS fare_class, economy AS seats FROM wide
UNION ALL
SELECT aircraft_code, 'comfort', comfort FROM wide
UNION ALL
SELECT aircraft_code, 'business', business FROM wide;
Lee la tabla una vez por columna. PostgreSQL y DuckDB pueden hacerlo en una sola pasada con un join LATERAL a una lista VALUES (SQL Server escribe la misma idea como CROSS APPLY (VALUES ...)):
SELECT w.aircraft_code, v.fare_class, v.seats
FROM wide w
CROSS JOIN LATERAL (
VALUES ('economy', w.economy), ('comfort', w.comfort), ('business', w.business)
) AS v(fare_class, seats)
WHERE w.aircraft_code = '773'
ORDER BY v.seats DESC;
| aircraft_code | fare_class | seats |
|---|---|---|
| 773 | economy | 324 |
| 773 | comfort | 48 |
| 773 | business | 30 |
(Las dos se ejecutan con el mismo WITH wide AS (...) de arriba.) SQL Server tiene además una cláusula UNPIVOT: FROM wide UNPIVOT (seats FOR fare_class IN (economy, comfort, business)) AS u.
Una diferencia a vigilar: el UNPIVOT de DuckDB y el de SQL Server descartan las celdas NULL, así que una ruta sin tarifa Comfort simplemente no tiene fila Comfort. UNION ALL y LATERAL VALUES las conservan como filas con valor NULL. Decide cuál espera tu siguiente paso.
Qué método usar
| situación | método |
|---|---|
| tiene que funcionar en cualquier base de datos | SUM(CASE ...) / MAX(CASE ...) |
| PostgreSQL, DuckDB, SQLite | COUNT(*) FILTER (WHERE ...) |
| DuckDB, exploración rápida | PIVOT (con IN para columnas estables) |
| SQL Server, Oracle | cláusula PIVOT, o CASE para varios agregados |
| conjunto de columnas desconocido | generar el SQL, o pivotar fuera de la base de datos |
| de ancho a largo | UNPIVOT, LATERAL VALUES o UNION ALL |
Para repasar los agregados que van dentro de cada columna (COUNT, SUM, MAX y cómo tratan NULL), mira la guía de funciones de agregación en SQL.
Preguntas frecuentes
¿Cómo convierto filas en columnas en SQL?
Agrupa por la columna que debe seguir siendo filas y escribe un agregado por cada columna nueva, limitado a un valor: SUM(CASE WHEN category = 'A' THEN amount ELSE 0 END) AS a. Esta agregación condicional funciona en cualquier base de datos SQL. DuckDB, SQL Server y Oracle ofrecen además un operador PIVOT.
¿PostgreSQL tiene PIVOT?
No. Usa agregación condicional con FILTER (WHERE ...) o CASE, o la función crosstab de la extensión tablefunc.
¿MySQL tiene PIVOT?
No. En MySQL se pivota con SUM(CASE ...) o SUM(IF(...)) y GROUP BY. Si la lista de columnas depende de los datos, construye la consulta con GROUP_CONCAT y ejecútala como sentencia preparada.
¿Puedo hacer un pivot sin saber de antemano los nombres de las columnas?
No con una sola consulta estática en la mayoría de bases de datos, porque las columnas de salida tienen que conocerse antes de ejecutarla. Usa SQL dinámico, el PIVOT de DuckDB sin lista IN, o devuelve datos largos y pivota en tu herramienta de BI o en una tabla dinámica de Excel.
¿Qué diferencia hay entre PIVOT y GROUP BY?
GROUP BY devuelve una fila por combinación de valores de agrupación (formato largo). Un pivot además despliega una de esas columnas de agrupación a lo ancho, como columnas separadas (formato ancho). Por dentro, un pivot es un GROUP BY con un agregado condicional por cada columna de salida.
También en English, Português (Brasil), Tiếng Việt, 简体中文