SQL · Guías · 30 de octubre de 2026 · 14 min de lectura
CASE WHEN en SQL: condiciones, categorías y agregación condicional

CASE es el si-entonces-si no de SQL. Mira cada fila, comprueba las condiciones en orden y devuelve el valor de la primera condición que se cumple. Si no se cumple ninguna, devuelve el valor de ELSE, o NULL si no hay ELSE:
CASE
WHEN condition_1 THEN value_1
WHEN condition_2 THEN value_2
ELSE fallback_value
END
CASE es una expresión, no una sentencia, así que puede ir en cualquier sitio donde vaya un valor: en SELECT, WHERE, GROUP BY, ORDER BY y dentro de funciones de agregado. Este último uso, la agregación condicional, es donde CASE hace su trabajo más valioso para un analista.
Los ejemplos usan la base de datos de una aerolínea del curso de SQL de Nomto: 33.121 vuelos entre 104 aeropuertos en el verano de 2026, con reservas, billetes, asientos y un programa de fidelización (valores en inglés, dinero en dólares). El SQL usa sintaxis de PostgreSQL y cada resultado sale de ejecutar la consulta; puedes pegarlas en la sala de SQL de Nomto y comprobarlo.
CASE con condiciones: cualquier condición
La forma habitual, llamada CASE con búsqueda (searched CASE), lleva una condición completa después de cada WHEN. Clasificar la flota por autonomía:
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 |
Fíjate en que la segunda rama dice solo range >= 3000, no range >= 3000 AND range < 6000. No necesita el límite superior, porque las filas con 6.000 o más ya las ha atrapado la primera rama. Esa es la regla que hace funcionar CASE: las ramas se comprueban de arriba abajo y gana la primera que coincide.
CASE simple: comparar una expresión con valores
Cuando todas las ramas comparan la misma expresión con una constante, la forma simple es más corta:
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'. Los valores también pueden ser expresiones: aquí el ELSE toma la primera palabra del nombre del modelo. (En este caso todas las ramas acaban dando la primera palabra de todos modos; en datos reales, las ramas explícitas son donde van las excepciones). La forma simple solo comprueba igualdad, así que para rangos, LIKE, IN o comprobaciones de NULL necesitas la forma con condiciones.
Agrupar números en categorías
Convertir un número continuo en tramos es el trabajo diario de CASE. ¿Cómo se reparten los precios de los tramos de vuelo?
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 |
Aquí hay dos pequeños trucos. Agrupar por el alias price_band funciona en PostgreSQL y DuckDB (el SQL estándar estricto te obligaría a repetir todo el CASE o a usar su posición, GROUP BY 1). Y ORDER BY MIN(amount) ordena los tramos por su orden real y no alfabéticamente, donde $100-299 iría antes que under $100.
La misma idea funciona con intervalos de tiempo. ¿Con cuánto retraso salieron los vuelos que llegaron?
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 |
Gana la primera coincidencia: el orden de las ramas importa
Pon las ramas en el orden equivocado y una categoría desaparece sin avisar:
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 vuelo con más de tres horas de retraso también lleva más de 15 minutos de retraso, así que se lo queda la primera rama y very late no salta nunca. Ni error ni aviso: solo una categoría con cero filas que nadie echa de menos. Con condiciones de «mayor que», pon primero el umbral más alto; con «menor que», primero el más bajo, como en los tramos de precio de arriba.
CASE y NULL
Dos reglas sobre NULL pillan a mucha gente con CASE.
Una condición que da NULL cuenta como falsa, así que la fila cae hasta el ELSE. En el programa de fidelización, a algunos socios les falta birth_year. Mira lo que hace esto:
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 |
Los 6.319 socios sin año de nacimiento han acabado etiquetados como «nacidos en 1980 o después». Trata el NULL en su propia rama, la primera:
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 |
El CASE simple no puede coincidir con NULL. CASE birth_year WHEN NULL THEN 'unknown' ELSE 'known' END etiqueta a los 53.000 socios como known, porque comprueba birth_year = NULL, que nunca es verdadero. Usa WHEN birth_year IS NULL en la forma con condiciones.
Y si omites el ELSE, las filas que no coinciden reciben 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 |
A veces es justo lo que quieres (es la base del truco con AVG de más abajo). Cuando no, un ELSE 'other' explícito hace visibles los huecos. Más sobre el comportamiento de NULL en la guía de NULL en SQL.
CASE en ORDER BY: un orden personalizado
El orden alfabético rara vez coincide con el orden del negocio. Los estados de un vuelo siguen un 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 |
El CASE devuelve un número que solo sirve para ordenar; no aparece en el resultado. El ELSE 6 manda cualquier valor inesperado, aquí Cancelled, al final en lugar de romper el orden.
CASE en GROUP BY: tus propios segmentos
Puedes agrupar por un CASE para crear segmentos que no existen como columna. ¿Los fines de semana hay más vuelos al día que entre semana?
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 los totales en bruto (23.555 frente a 9.566) no tendría sentido, porque hay muchos más días laborables. Dividir por el número de días de cada segmento da una comparación justa: los fines de semana tienen alrededor de un 5 % más de vuelos.
Agregación condicional
Mete un CASE dentro de un agregado y podrás calcular varias métricas filtradas en una sola pasada por la tabla, una por columna. Es el patrón de CASE más útil en analítica.
Contar con SUM(CASE … THEN 1 ELSE 0 END)
¿Cuántos vuelos canceló cada gran aeropuerto y qué porcentaje supone?
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 |
Un WHERE status = 'Cancelled' no podría hacer esto: tiraría el resto de vuelos y, con ellos, el denominador. La agregación condicional conserva todas las filas y cuenta solo las que te interesan. Fíjate en el 100.0: con 100 y conteos enteros, PostgreSQL haría división entera y devolvería 0. En DuckDB, / siempre devuelve un resultado con decimales, así que el mismo fallo queda oculto hasta que la consulta se ejecuta en PostgreSQL; DuckDB usa // para la división entera.
Hay tres formas equivalentes de contar con condición, y una tentadora que está mal:
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 |
SUM(CASE ... THEN 1 ELSE 0 END)suma un 1 por cada coincidencia.COUNT(CASE ... THEN 1 END)funciona porqueCOUNT(expresión)se salta losNULL, y sinELSElas filas que no coinciden sonNULL.AVG(CASE ... THEN 1.0 ELSE 0 END)da directamente la proporción: la media de unos y ceros es la proporción de unos.
La incorrecta es COUNT(CASE ... THEN 1 ELSE 0 END). Cero no es NULL, así que COUNT lo cuenta y obtienes 16.707: todas las filas. Si usas COUNT, quita el ELSE; si usas SUM, deja el ELSE 0.
FILTER: una sintaxis más limpia
PostgreSQL y DuckDB admiten la cláusula estándar FILTER, que dice lo mismo de forma más directa. Un mapa de asientos por avión, una columna por clase:
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 |
Esto es un pivot: las filas de fare_conditions convertidas en columnas. FILTER no existe en MySQL, SQL Server ni Oracle, así que si tu código tiene que ejecutarse allí, usa SUM(CASE ...), que funciona en todas partes.
Sumar importes, no solo contar
Un CASE dentro de SUM puede devolver el propio valor en lugar de 1. Ingresos mensuales de los vuelos llegados, separados por clase:
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 sale más bajo porque la instantánea de datos termina el 11 de agosto; el resto del mes todavía no se ha volado).
Y sin ELSE, AVG solo promedia las filas que coinciden, porque las demás son NULL y los agregados se saltan los NULL:
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 |
Aquí ELSE 0 y la ausencia de ELSE dan resultados distintos, y los dos son correctos: el SUM necesita los ceros para que el total sea 0 y no NULL, mientras que el AVG no debe incluir ceros, o promediaría sobre todos los tramos. Comfort no tiene ningún tramo de 600 dólares o más, así que su media es NULL: no hay nada que promediar.
Tipos: todas las ramas deben devolver el mismo tipo
Todos los valores de THEN y ELSE tienen que encajar en un mismo tipo de dato. CASE WHEN ... THEN 1 ELSE 'none' END mezcla un entero con texto. PostgreSQL lo rechaza con «invalid input syntax for type integer». DuckDB también falla, con un error de conversión, pero solo cuando una fila llega de verdad a la rama 'none', así que una consulta puede pasar la prueba con una muestra pequeña y romperse más tarde. Haz un cast explícito: THEN CAST(x AS VARCHAR), o devuelve NULL en lugar de un texto de relleno.
CASE en WHERE: normalmente no hace falta
Puedes escribir WHERE CASE WHEN ... THEN ... END, pero un filtro casi siempre se lee mejor con lógica AND / OR normal. WHERE CASE WHEN status = 'Arrived' THEN actual_departure > '2026-08-01' ELSE false END es simplemente WHERE status = 'Arrived' AND actual_departure > '2026-08-01'. Las condiciones normales también son más fáciles de aprovechar con índices para el optimizador.
Atajos relacionados
COALESCE(a, b)esCASE WHEN a IS NOT NULL THEN a ELSE b END.NULLIF(a, b)esCASE WHEN a = b THEN NULL ELSE a END; su uso principal es evitar la división por cero.- MySQL tiene
IF(cond, a, b)y SQL Server tieneIIF(cond, a, b). Son más cortos pero no portables;CASEfunciona en todas partes.
Para más patrones de GROUP BY con conteos condicionales, mira GROUP BY y HAVING. Los pivots con agregación condicional también son un clásico de las preguntas de entrevista de SQL.
Preguntas frecuentes
¿Qué diferencia hay entre el CASE simple y el CASE con condiciones?
El CASE simple, CASE x WHEN 1 THEN ..., compara una expresión con valores por igualdad. El CASE con condiciones, CASE WHEN condición THEN ..., admite cualquier condición: rangos, LIKE, IN, IS NULL, combinaciones con AND. La forma con condiciones puede hacer todo lo que hace la simple.
¿Qué devuelve CASE si no se cumple ninguna condición?
El valor de ELSE, o NULL si no hay ELSE.
¿Se puede usar CASE en un WHERE?
Sí, pero rara vez es la opción más clara. La mayoría de expresiones CASE dentro de WHERE se pueden reescribir como condiciones normales con AND / OR.
¿Cómo cuento en SQL las filas que cumplen una condición?
Con agregación condicional: SUM(CASE WHEN condición THEN 1 ELSE 0 END), COUNT(CASE WHEN condición THEN 1 END) o, en PostgreSQL y DuckDB, COUNT(*) FILTER (WHERE condición). Las tres mantienen el resto de filas disponibles para totales y porcentajes.
¿Se pueden anidar expresiones CASE?
Sí, un THEN o un ELSE puede contener otro CASE. Aun así, normalmente un único CASE con condiciones combinadas mediante AND se lee mejor que uno anidado.
También en English, Português (Brasil), Tiếng Việt, 简体中文