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

CASE WHEN no SQL: condições, categorias e agregação condicional com exemplos

Placas de aeroporto indicando aos viajantes portões diferentes de acordo com o bilhete

O CASE é o “se-então-senão” do SQL. Ele olha cada linha, verifica as condições em ordem e devolve o valor da primeira condição verdadeira. Se nenhuma for verdadeira, devolve o valor do ELSE, ou NULL se não houver ELSE:

CASE
  WHEN condition_1 THEN value_1
  WHEN condition_2 THEN value_2
  ELSE fallback_value
END

O CASE é uma expressão, não um comando, então pode ir em qualquer lugar onde cabe um valor: no SELECT, no WHERE, no GROUP BY, no ORDER BY e dentro de funções de agregação. Esse último uso, a agregação condicional, é onde o CASE faz o trabalho mais valioso para quem analisa dados.

Os exemplos rodam no banco de dados de uma companhia aérea usado no curso de SQL da Nomto: 33.121 voos entre 104 aeroportos entre julho e setembro de 2026, com reservas, bilhetes, assentos e um programa de fidelidade. Os valores estão em dólares americanos e os textos dos dados em inglês. O SQL segue a sintaxe do PostgreSQL, e cada resultado saiu da execução real da consulta.

CASE com condições: qualquer condição

A forma mais comum, chamada de CASE pesquisado (searched), aceita uma condição completa depois de cada WHEN. Separando a frota por categoria de alcance:

SELECT model, range,
       CASE
         WHEN range >= 6000 THEN 'long-haul'
         WHEN range >= 3000 THEN 'medium-haul'
         ELSE 'short-haul'
       END AS category
FROM aircrafts
ORDER BY range DESC;
model range category
Boeing 777-300 11100 long-haul
Boeing 767-300 7900 long-haul
Airbus A319-100 6700 long-haul
Airbus A320-200 5700 medium-haul
Airbus A321-200 5600 medium-haul
Boeing 737-300 4200 medium-haul
Sukhoi Superjet-100 3000 medium-haul
Bombardier CRJ-200 2700 short-haul
Cessna 208 Caravan 1200 short-haul

Repare que o segundo ramo diz só range >= 3000, e não range >= 3000 AND range < 6000. Ele não precisa do limite superior, porque as linhas com 6.000 ou mais já foram pegas pelo primeiro ramo. Essa é a regra que faz o CASE funcionar: os ramos são verificados de cima para baixo, e a primeira correspondência vence.

CASE simples: comparar uma expressão com valores

Quando todos os ramos comparam a mesma expressão com uma constante, a forma simples é mais curta:

SELECT aircraft_code, model,
       CASE aircraft_code
         WHEN 'SU9' THEN 'Sukhoi'
         WHEN 'CR2' THEN 'Bombardier'
         WHEN 'CN1' THEN 'Cessna'
         ELSE split_part(model, ' ', 1)
       END AS maker
FROM aircrafts
ORDER BY aircraft_code;
aircraft_code model maker
319 Airbus A319-100 Airbus
320 Airbus A320-200 Airbus
321 Airbus A321-200 Airbus
733 Boeing 737-300 Boeing
763 Boeing 767-300 Boeing
773 Boeing 777-300 Boeing
CN1 Cessna 208 Caravan Cessna
CR2 Bombardier CRJ-200 Bombardier
SU9 Sukhoi Superjet-100 Sukhoi

CASE aircraft_code WHEN 'SU9' significa CASE WHEN aircraft_code = 'SU9'. Os valores também podem ser expressões: o ELSE aqui pega a primeira palavra do nome do modelo. (Neste caso, todos os ramos acabam dando a primeira palavra de qualquer jeito; em dados reais, os ramos explícitos são onde ficam as exceções.) A forma simples só testa igualdade, então para faixas, LIKE, IN ou verificações de NULL você precisa da forma com condições.

Separar números em faixas

Transformar um número contínuo em faixas é o trabalho do dia a dia do CASE. Como se distribuem os preços dos trechos?

SELECT CASE
         WHEN amount < 100 THEN 'under $100'
         WHEN amount < 300 THEN '$100-299'
         WHEN amount < 600 THEN '$300-599'
         ELSE '$600+'
       END AS price_band,
       COUNT(*) AS segments
FROM ticket_flights
GROUP BY price_band
ORDER BY MIN(amount);
price_band segments
under $100 359344
$100-299 525159
$300-599 93383
$600+ 67840

Dois truques pequenos aqui. Agrupar pelo alias price_band funciona no PostgreSQL e no DuckDB (o SQL padrão estrito obrigaria você a repetir o CASE inteiro ou usar a posição, GROUP BY 1). E ORDER BY MIN(amount) ordena as faixas pela ordem real, e não pela ordem alfabética, em que $100-299 viria antes de under $100.

A mesma ideia funciona com intervalos de tempo. Quanto atrasaram na partida os voos que chegaram?

SELECT CASE
         WHEN actual_departure - scheduled_departure <= INTERVAL '5 minutes' THEN 'up to 5 min'
         WHEN actual_departure - scheduled_departure <= INTERVAL '15 minutes' THEN '6-15 min'
         WHEN actual_departure - scheduled_departure <= INTERVAL '3 hours' THEN '15 min to 3 h'
         ELSE 'over 3 h'
       END AS delay_group,
       COUNT(*) AS flights
FROM flights
WHERE status = 'Arrived'
GROUP BY delay_group
ORDER BY MIN(actual_departure - scheduled_departure);
delay_group flights
up to 5 min 14657
6-15 min 1254
15 min to 3 h 209
over 3 h 587

A primeira correspondência vence: a ordem dos ramos importa

Coloque os ramos na ordem errada e uma categoria some em silêncio:

SELECT CASE
         WHEN actual_departure - scheduled_departure > INTERVAL '15 minutes' THEN 'late'
         WHEN actual_departure - scheduled_departure > INTERVAL '3 hours' THEN 'very late'
         ELSE 'on time'
       END AS delay_group,
       COUNT(*) AS flights
FROM flights
WHERE status = 'Arrived'
GROUP BY delay_group
ORDER BY flights DESC;
delay_group flights
on time 15911
late 796

Todo voo com mais de três horas de atraso também tem mais de 15 minutos de atraso, então o primeiro ramo fica com ele e o very late nunca dispara. Nenhum erro, nenhum aviso, só uma categoria com zero linhas que ninguém percebe que está faltando. Com condições de “maior que”, coloque o maior limite primeiro; com “menor que”, o menor primeiro, como nas faixas de preço acima.

CASE e NULL

Duas regras de NULL pegam as pessoas desprevenidas no CASE.

Uma condição que dá NULL conta como falsa, então a linha cai no ELSE. No programa de fidelidade, birth_year está vazio para alguns membros. Veja o que isto faz:

SELECT CASE
         WHEN birth_year < 1980 THEN 'born before 1980'
         ELSE 'born 1980 or later'
       END AS age_group,
       COUNT(*) AS members
FROM loyalty_members
GROUP BY age_group
ORDER BY age_group;
age_group members
born 1980 or later 35666
born before 1980 17334

Os 6.319 membros sem ano de nascimento foram rotulados como “born 1980 or later”. Trate o NULL num ramo próprio, primeiro:

SELECT CASE
         WHEN birth_year IS NULL THEN 'unknown'
         WHEN birth_year < 1980 THEN 'born before 1980'
         ELSE 'born 1980 or later'
       END AS age_group,
       COUNT(*) AS members
FROM loyalty_members
GROUP BY age_group
ORDER BY age_group;
age_group members
born 1980 or later 29347
born before 1980 17334
unknown 6319

O CASE simples não consegue encontrar NULL. CASE birth_year WHEN NULL THEN 'unknown' ELSE 'known' END rotula todos os 53.000 membros como “known”, porque testa birth_year = NULL, que nunca é verdadeiro. Use WHEN birth_year IS NULL na forma com condições.

E se você deixa o ELSE de fora, as linhas sem correspondência recebem NULL:

SELECT model,
       CASE
         WHEN range >= 6000 THEN 'long-haul'
         WHEN range >= 3000 THEN 'medium-haul'
       END AS category
FROM aircrafts
WHERE range < 3000;
model category
Cessna 208 Caravan NULL
Bombardier CRJ-200 NULL

Às vezes é exatamente isso que você quer (é a base do truque com AVG mais abaixo). Quando não é, um ELSE 'other' explícito deixa as lacunas visíveis. Mais sobre o comportamento do NULL no guia de NULL no SQL.

CASE no ORDER BY: uma ordenação personalizada

A ordem alfabética raramente coincide com a ordem do negócio. Os status de voo têm um ciclo de vida natural:

SELECT status, COUNT(*) AS flights
FROM flights
GROUP BY status
ORDER BY CASE status
           WHEN 'Scheduled' THEN 1
           WHEN 'On Time' THEN 2
           WHEN 'Delayed' THEN 3
           WHEN 'Departed' THEN 4
           WHEN 'Arrived' THEN 5
           ELSE 6
         END;
status flights
Scheduled 15383
On Time 518
Delayed 41
Departed 58
Arrived 16707
Cancelled 414

O CASE devolve um número usado só para ordenar; ele não aparece na saída. O ELSE 6 manda qualquer valor inesperado, aqui Cancelled, para o fim, em vez de bagunçar a ordem.

CASE no GROUP BY: seus próprios segmentos

Você pode agrupar por um CASE para criar segmentos que não existem como coluna. Os fins de semana têm mais voos por dia que os dias úteis?

SELECT CASE
         WHEN EXTRACT(ISODOW FROM scheduled_departure) IN (6, 7) THEN 'weekend'
         ELSE 'weekday'
       END AS day_type,
       COUNT(*) AS flights,
       COUNT(DISTINCT CAST(scheduled_departure AS date)) AS days,
       ROUND(COUNT(*) * 1.0 / COUNT(DISTINCT CAST(scheduled_departure AS date)), 1) AS flights_per_day
FROM flights
GROUP BY day_type
ORDER BY day_type;
day_type flights days flights_per_day
weekday 23555 44 535.3
weekend 9566 17 562.7

Comparar os totais brutos (23.555 contra 9.566) não faria sentido, já que há muito mais dias úteis. Dividir pelo número de dias de cada segmento dá uma comparação justa: os fins de semana são cerca de 5% mais movimentados.

Agregação condicional

Coloque um CASE dentro de uma agregação e você calcula várias métricas filtradas numa única passada pela tabela, uma por coluna. Este é o padrão de CASE mais útil em análise de dados.

Contar com SUM(CASE … THEN 1 ELSE 0 END)

Quantos voos cada aeroporto grande cancelou, e que percentual isso representa?

SELECT departure_airport,
       COUNT(*) AS flights,
       SUM(CASE WHEN status = 'Cancelled' THEN 1 ELSE 0 END) AS cancelled,
       ROUND(100.0 * SUM(CASE WHEN status = 'Cancelled' THEN 1 ELSE 0 END) / COUNT(*), 2) AS cancel_pct
FROM flights
WHERE departure_airport IN ('SVO', 'DME', 'VKO', 'LED')
GROUP BY departure_airport
ORDER BY departure_airport;
departure_airport flights cancelled cancel_pct
DME 3217 31 0.96
LED 1900 15 0.79
SVO 2981 26 0.87
VKO 1719 20 1.16

Um WHERE status = 'Cancelled' não daria conta disso: ele jogaria fora os outros voos e, com eles, o denominador. A agregação condicional mantém todas as linhas e conta só as que interessam. Repare no 100.0: com 100 e contagens inteiras, o PostgreSQL faria divisão inteira e devolveria 0. No DuckDB, o / sempre devolve um resultado fracionário, então o mesmo bug fica escondido ali até a consulta rodar no PostgreSQL; o DuckDB usa // para divisão inteira.

Há três jeitos equivalentes de contar com condição, e um errado bem tentador:

SELECT COUNT(*) AS arrived,
       SUM(CASE WHEN actual_departure - scheduled_departure > INTERVAL '15 minutes' THEN 1 ELSE 0 END) AS late,
       COUNT(CASE WHEN actual_departure - scheduled_departure > INTERVAL '15 minutes' THEN 1 END) AS late_count,
       ROUND(AVG(CASE WHEN actual_departure - scheduled_departure > INTERVAL '15 minutes' THEN 1.0 ELSE 0 END) * 100, 2) AS late_pct
FROM flights
WHERE status = 'Arrived';
arrived late late_count late_pct
16707 796 796 4.76

O errado é COUNT(CASE ... THEN 1 ELSE 0 END). Zero não é NULL, então o COUNT conta o zero, e você recebe 16.707: todas as linhas. Se usar COUNT, tire o ELSE; se usar SUM, mantenha o ELSE 0.

FILTER: a sintaxe mais limpa

O PostgreSQL e o DuckDB suportam a cláusula padrão FILTER, que diz a mesma coisa de forma mais direta. Um mapa de assentos por avião, uma coluna por classe:

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
ORDER BY aircraft_code;
aircraft_code economy comfort business
319 96 0 20
320 120 0 20
321 142 0 28
733 118 0 12
763 192 0 30
773 324 48 30
CN1 12 0 0
CR2 50 0 0
SU9 85 0 12

Isso é um pivot: as linhas de fare_conditions viraram colunas. O FILTER não existe no MySQL, no SQL Server nem no Oracle, então, se o seu código precisa rodar neles, use SUM(CASE ...); funciona em qualquer banco.

Somar valores, não só contar

O CASE dentro do SUM pode devolver o próprio valor em vez de 1. Receita mensal dos voos que chegaram, separada por classe:

SELECT date_trunc('month', f.scheduled_departure)::date AS month,
       ROUND(SUM(CASE WHEN tf.fare_conditions = 'Economy' THEN tf.amount ELSE 0 END)) AS economy,
       ROUND(SUM(CASE WHEN tf.fare_conditions = 'Comfort' THEN tf.amount ELSE 0 END)) AS comfort,
       ROUND(SUM(CASE WHEN tf.fare_conditions = 'Business' THEN tf.amount ELSE 0 END)) AS business
FROM ticket_flights tf
JOIN flights f ON f.flight_id = tf.flight_id
WHERE f.status = 'Arrived'
GROUP BY month
ORDER BY month;
month economy comfort business
2026-07-01 47444895 1899027 17733606
2026-08-01 33400255 1327303 12473544

(Agosto é menor porque o retrato dos dados termina em 11 de agosto; o resto do mês ainda não voou.)

E sem ELSE, o AVG calcula a média só das linhas que correspondem, porque as outras são NULL e as agregações ignoram NULLs:

SELECT fare_conditions,
       ROUND(SUM(amount)) AS revenue,
       ROUND(SUM(CASE WHEN amount >= 600 THEN amount ELSE 0 END)) AS revenue_600plus,
       ROUND(AVG(CASE WHEN amount >= 600 THEN amount END), 2) AS avg_600plus
FROM ticket_flights
GROUP BY fare_conditions
ORDER BY revenue DESC;
fare_conditions revenue revenue_600plus avg_600plus
Economy 146956844 26449215 634.9
Business 55051796 31703523 1210.94
Comfort 5661169 0 NULL

Aqui ELSE 0 e a ausência de ELSE dão resultados diferentes, e os dois estão certos: o SUM precisa dos zeros para que o total seja 0 e não NULL, enquanto o AVG não pode incluir zeros, senão faria a média sobre todos os trechos. A Comfort não tem nenhum trecho de US$ 600 ou mais, então a média dela é NULL: não há nada para calcular.

Tipos: todos os ramos devem devolver o mesmo tipo

Todos os valores de THEN e ELSE precisam caber num único tipo de dado. CASE WHEN ... THEN 1 ELSE 'none' END mistura inteiro com texto. O PostgreSQL recusa com “invalid input syntax for type integer”. O DuckDB também falha, com um erro de conversão, mas só quando alguma linha chega de fato ao ramo 'none', então uma consulta pode passar num teste com uma amostra pequena e quebrar depois. Faça o cast explícito: THEN CAST(x AS VARCHAR), ou devolva NULL em vez de um texto de preenchimento.

CASE no WHERE: em geral, não é necessário

Você pode escrever WHERE CASE WHEN ... THEN ... END, mas um filtro quase sempre fica mais legível com lógica simples de AND / OR. WHERE CASE WHEN status = 'Arrived' THEN actual_departure > '2026-08-01' ELSE false END é só WHERE status = 'Arrived' AND actual_departure > '2026-08-01'. Condições simples também são mais fáceis de o otimizador aproveitar com índices.

Atalhos relacionados

Para mais padrões de GROUP BY com contagens condicionais, veja GROUP BY e HAVING. Pivots com agregação condicional também aparecem com frequência nas perguntas de entrevista SQL.

Perguntas frequentes

Qual a diferença entre CASE simples e CASE com condições?

O CASE x WHEN 1 THEN ... simples compara uma expressão com valores por igualdade. O CASE WHEN condição THEN ... aceita qualquer condição: faixas, LIKE, IN, IS NULL, combinações com AND. A forma com condições faz tudo o que a simples faz.

O que o CASE devolve se nenhuma condição for atendida?

O valor do ELSE, ou NULL se não houver ELSE.

Posso usar CASE no WHERE?

Pode, mas raramente é a opção mais clara. A maioria das expressões CASE no WHERE pode ser reescrita como condições comuns de AND / OR.

Como contar linhas que atendem a uma condição no SQL?

Use agregação condicional: SUM(CASE WHEN condição THEN 1 ELSE 0 END), COUNT(CASE WHEN condição THEN 1 END) ou, no PostgreSQL e no DuckDB, COUNT(*) FILTER (WHERE condição). As três mantêm as outras linhas disponíveis para totais e percentuais.

Posso aninhar expressões CASE?

Sim, um THEN ou um ELSE pode conter outro CASE. Mas, em geral, um único CASE com condições combinadas por AND fica mais legível do que o aninhamento.

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