SQL para análise de dados · Guias · 30 de outubro de 2026 · 13 min de leitura

PIVOT no SQL: transformar linhas em colunas (e unpivot), com exemplos

Um longo painel de partidas girando e virando uma grade larga de meses e aviões, ilustração plana

Fazer um pivot no SQL é transformar os valores de uma coluna em colunas separadas, uma por valor: é a “tabela dinâmica” do Excel, só que escrita em SQL. O jeito portável é a agregação condicional: GROUP BY pelo rótulo da linha e uma agregação por coluna de saída, cada uma contando ou somando só o seu próprio valor, SUM(CASE WHEN month = 7 THEN 1 ELSE 0 END) AS jul. Isso roda em qualquer banco. Alguns motores acrescentam um operador PIVOT que escreve essas colunas para você (DuckDB, SQL Server, Oracle, Snowflake, BigQuery); o PostgreSQL tem a função crosstab no lugar; o MySQL não tem nenhum dos dois. O caminho inverso, o unpivot, transforma colunas de volta em linhas.

Os exemplos rodam no banco de dados de uma companhia aérea usado no curso de SQL da Nomto, com sintaxe do PostgreSQL executada no DuckDB (o motor da sala de SQL da Nomto). Os textos dos dados estão em inglês, e cada tabela de resultado saiu da execução real da consulta. Os trechos para SQL Server e para o crosstab do PostgreSQL estão marcados como tais e não foram executados aqui.

Dados longos e dados largos

Um GROUP BY por duas colunas devolve dados longos: uma linha por combinação. Voos por modelo de avião e por mês:

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

As seis primeiras de 24 linhas. Dados longos são o que bancos, gráficos e o SQL seguinte preferem. Quem lê um relatório costuma querer a versão larga: uma linha por modelo, uma coluna por mês. Chegar lá é o pivot.

Pivot com CASE: o jeito portável

Agrupe só pelo rótulo da linha e monte cada coluna com uma agregação que olha para um único mês:

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

(A malha de voos vai de 12 de julho a 10 de setembro, então julho e setembro são meses parciais.)

Como funciona: cada linha de voo passa pelas três expressões CASE, e cada uma soma 1 só se a linha for do seu mês. O GROUP BY a.model então reduz as linhas a uma por modelo. O total da linha sai de graça: COUNT(*) no mesmo SELECT. O nono modelo, o Airbus A320-200, não tem nenhum voo, então o inner join o deixa de fora; comece por aircrafts com um LEFT JOIN se as linhas vazias precisarem aparecer.

Isso funciona sem mudanças no PostgreSQL, MySQL, SQL Server, Oracle, SQLite e DuckDB (só muda a função que extrai o mês: MONTH() no MySQL e no SQL Server). Para somar um valor em vez de contar, devolva o valor no ramo THEN: SUM(CASE WHEN ... THEN amount ELSE 0 END). O guia de CASE WHEN no SQL explica os detalhes de SUM versus COUNT na seção de agregação condicional.

FILTER: o mesmo pivot, mais curto

PostgreSQL, DuckDB e SQLite aceitam a cláusula padrão FILTER, que fica mais legível quando há muitas colunas. Como o pivot coloca números relacionados lado a lado, dá para calcular razões entre as novas colunas no passo seguinte. Eventos do app por plataforma, com taxas de conversão:

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

A web converte melhor nas duas etapas. Em formato longo, a mesma comparação pediria um self join ou funções de janela; depois do pivot, é aritmética simples entre colunas. (São contagens brutas de eventos; a tabela também tem eventos reenviados, que o guia encontrar duplicados no SQL remove.)

MySQL, SQL Server e Oracle não têm FILTER; use SUM(CASE ...) neles.

Pivotar valores, não contagens: MAX(CASE …)

Quando cada célula contém exatamente um valor, como um atributo guardado como linha, use MAX (ou MIN) para pegá-lo. Tarifas atuais a partir de Sheremetyevo, uma coluna por classe:

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

Aqui o CASE não tem ELSE, então uma classe ausente sai como NULL, o que está certo: essas rotas não têm tarifa Comfort (só 10 rotas da tabela inteira têm), e um preço 0 seria mentira. Com contagens é o contrário: 0 é a resposta certa para “nenhum voo”, e ELSE 0 ou COUNT dá esse 0. O MAX também funciona com texto e datas, o que faz dele o jeito padrão de transformar linhas chave-valor (nome do atributo, valor do atributo) em colunas.

O operador PIVOT no DuckDB

O DuckDB tem um comando PIVOT. O ON indica a coluna cujos valores viram colunas, o USING a agregação, e o GROUP BY o rótulo da linha. Com uma lista IN explícita, você escolhe as colunas e os nomes delas:

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

Os mesmos números da versão com CASE. Dois extras deixam o PIVOT do DuckDB prático. Várias agregações ao mesmo tempo, cada uma gerando uma coluna 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

E, sem IN, ele descobre as colunas nos dados:

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

Isso é ótimo para explorar, mas repare na coluna On Time, com um espaço no nome, e no fato de que o conjunto de colunas agora depende dos dados. Mais sobre isso abaixo.

PIVOT no SQL Server (e no Oracle)

No SQL Server, muito usado em empresas no Brasil, o PIVOT é uma cláusula dentro do FROM. Não foi executado aqui; o formato é:

-- 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;

A armadilha: não existe GROUP BY. O SQL Server agrupa por todas as colunas da origem que não são usadas no pivot. É por isso que a origem é uma subconsulta só com model, m e flight_id; selecione f.* ali e você recebe uma linha por voo, em vez de uma por modelo. A lista IN recebe nomes de coluna entre colchetes, e só uma agregação é permitida. O PIVOT do Oracle é parecido, mas aceita várias agregações e literais entre aspas no IN.

PostgreSQL: crosstab

O PostgreSQL não tem PIVOT. A extensão tablefunc oferece o crosstab, que recebe uma consulta que devolve (rótulo da linha, categoria, valor) e espalha esses valores em colunas. Não foi executado aqui:

-- PostgreSQL, requer: 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);

Você ainda precisa listar as colunas de saída e os tipos delas em AS ct(...), a consulta de origem precisa estar ordenada pelo rótulo da linha, e as células vazias voltam como NULL, não 0. Use sempre a forma com dois argumentos mostrada aqui: a versão com um argumento preenche as colunas da esquerda para a direita e desloca valores para a coluna errada quando falta uma categoria numa linha. A maioria de quem usa PostgreSQL acha o COUNT(*) FILTER (WHERE ...) mais simples e fica com ele.

Pivot dinâmico: quando você não conhece as colunas

Uma consulta SQL precisa declarar as colunas de saída antes de rodar. É por isso que todos os métodos acima listam os valores (jul, aug, sep) à mão, e por isso “pivotar por todos os valores distintos, sejam quais forem” não é possível numa consulta simples na maioria dos bancos. As opções:

Unpivot: colunas de volta para linhas

O unpivot transforma uma tabela larga em longa: cada par (linha, coluna) vira uma linha com uma coluna de “nome” e uma de “valor”. Você precisa disso quando os dados chegam como planilha com uma coluna por mês, ou para fazer um gráfico de uma tabela larga. Comece com um mapa de assentos largo, uma coluna por classe, e faça o unpivot no 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

A alternativa portável funciona em qualquer lugar: um SELECT por coluna, colados com 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;

Ela lê a tabela uma vez por coluna. PostgreSQL e DuckDB fazem isso numa única passada com um join LATERAL a uma lista VALUES (o SQL Server escreve a mesma ideia 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

(As duas rodam com o mesmo WITH wide AS (...) de cima.) O SQL Server também tem uma cláusula UNPIVOT: FROM wide UNPIVOT (seats FOR fare_class IN (economy, comfort, business)) AS u.

Uma diferença a observar: o UNPIVOT do DuckDB e o do SQL Server descartam as células NULL, então uma rota sem tarifa Comfort simplesmente não tem linha Comfort. UNION ALL e LATERAL VALUES mantêm essas células como linhas com valor NULL. Decida qual dos dois o seu próximo passo espera.

Qual método usar

situação método
precisa rodar em qualquer banco SUM(CASE ...) / MAX(CASE ...)
PostgreSQL, DuckDB, SQLite COUNT(*) FILTER (WHERE ...)
DuckDB, exploração rápida PIVOT (com IN para colunas estáveis)
SQL Server, Oracle cláusula PIVOT, ou CASE para várias agregações
conjunto de colunas desconhecido gerar o SQL, ou pivotar fora do banco
de largo para longo UNPIVOT, LATERAL VALUES ou UNION ALL

Perguntas frequentes

Como transformar linhas em colunas no SQL?

Agrupe pela coluna que deve continuar como linha e escreva uma agregação por coluna nova, cada uma limitada a um valor: SUM(CASE WHEN category = 'A' THEN amount ELSE 0 END) AS a. Essa agregação condicional funciona em qualquer banco SQL. DuckDB, SQL Server e Oracle também oferecem um operador PIVOT.

O PostgreSQL tem PIVOT?

Não. Use agregação condicional com FILTER (WHERE ...) ou CASE, ou a função crosstab da extensão tablefunc.

O MySQL tem PIVOT?

Não. O MySQL pivota com SUM(CASE ...) ou SUM(IF(...)) e GROUP BY. Para uma lista de colunas que depende dos dados, monte a consulta com GROUP_CONCAT e rode como prepared statement.

Dá para fazer pivot sem saber os nomes das colunas antes?

Não numa única consulta estática, na maioria dos bancos, porque as colunas de saída precisam ser conhecidas antes de a consulta rodar. Use SQL dinâmico, o PIVOT do DuckDB sem lista IN, ou devolva dados longos e pivote na ferramenta de BI ou na planilha.

Qual a diferença entre PIVOT e GROUP BY?

O GROUP BY devolve uma linha por combinação de valores de agrupamento (formato longo). O pivot, além disso, espalha uma dessas colunas de agrupamento pela página como colunas separadas (formato largo). Por baixo, um pivot é um GROUP BY com uma agregação condicional por coluna de saída.

Outros idiomas: English, Español, Tiếng Việt, 简体中文