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

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:
- Listar os valores. O melhor para relatórios: as colunas são estáveis, têm nome e uma ordem que faz sentido. Se aparecer um status novo nos dados, ele fica de fora sem aviso, então acrescente uma coluna
other(COUNT(*) FILTER (WHERE status NOT IN (...))) quando isso importar. - Gerar o SQL. No SQL Server, monte a lista de colunas a partir dos dados com
STRING_AGGe rode comsp_executesql; no PostgreSQL, uma função comEXECUTE; ou monte a consulta em Python ou na sua ferramenta de BI. - Deixar o motor fazer. O
PIVOTdo DuckDB semIN, oPIVOT ... ANYdo Snowflake. Prático, mas o formato do resultado muda com os dados, o que quebra o código seguinte que espera colunas fixas. - Não pivotar no SQL. Devolva dados longos e pivote na tabela dinâmica do Excel ou do Google Sheets, no Power BI ou no pandas (
pivot_table). Muitas vezes é a divisão de trabalho mais limpa.
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, 简体中文