SQL · Guías · 30 de octubre de 2026 · 13 min de lectura

PIVOT en SQL: convertir filas en columnas (y UNPIVOT), con ejemplos

Un largo panel de salidas que gira hasta convertirse en una cuadrícula ancha de meses y aviones, ilustración plana

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:

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, 简体中文