Curso SQL · Northwind
Tema 16 de 64
Bloque 3 · Transformar valores PostgreSQL 17 northwind

CASE: condicionales en SQL

1. Qué es

CASE es el if / else de SQL. Evalúa condiciones en orden y devuelve el valor de la primera que se cumple.

Y es más importante de lo que parece, porque SQL no tiene bucles ni variables: CASE es prácticamente la única lógica de decisión que existe dentro de una consulta. Con él se hacen tres cosas que se usan a diario:

  1. Clasificar — convertir un número en una categoría (económico, medio, premium).
  2. Ordenar a medida — un orden que no es alfabético ni numérico.
  3. Pivotar — convertir filas en columnas.

2. Sintaxis

Hay dos formas.

Buscada — la que se usa el 90% de las veces. Cada WHEN lleva su propia condición completa:

CASE WHEN condición1 THEN valor1
     WHEN condición2 THEN valor2
     ELSE valor_por_defecto
END

Simple — compara una expresión contra valores concretos. Más corta, pero solo sirve para igualdad:

CASE expresión
     WHEN valor1 THEN resultado1
     WHEN valor2 THEN resultado2
     ELSE otro
END

Tres reglas:

  • Se evalúa de arriba abajo y gana el primer WHEN que se cumple. El orden no es decorativo.
  • ELSE es opcional. Si falta y nada coincide, devuelve NULL.
  • Todos los THEN deben devolver tipos compatibles. Un THEN 'texto' junto a un THEN 0 da error de tipos.

3. Ejemplos sobre Northwind

3.1 Clasificar

SELECT product_name, unit_price,
       CASE WHEN unit_price < 20 THEN 'económico'
            WHEN unit_price < 50 THEN 'medio'
            ELSE 'premium'
       END AS gama
FROM products
ORDER BY unit_price DESC
LIMIT 5;
      product_name       | unit_price |  gama
-------------------------+------------+---------
 Côte de Blaye           |     263.50 | premium
 Thüringer Rostbratwurst |     123.79 | premium
 Mishi Kobe Niku         |      97.00 | premium

Fíjate en que el segundo WHEN dice unit_price < 50 a secas, no BETWEEN 20 AND 50. No hace falta: si la fila llegó al segundo WHEN es porque el primero falló, así que ya sabemos que vale 20 o más. Escribir el rango completo es redundante y una fuente de huecos.

3.2 El orden de los WHEN decide el resultado

La misma clasificación, con los dos primeros WHEN intercambiados:

SELECT product_name, unit_price,
       CASE WHEN unit_price < 50 THEN 'medio'
            WHEN unit_price < 20 THEN 'económico'
            ELSE 'premium'
       END AS gama_mal
FROM products
WHERE unit_price < 20
LIMIT 3;
 product_name  | unit_price | gama_mal
---------------+------------+----------
 Chai          |      18.00 | medio
 Chang         |      19.00 | medio
 Aniseed Syrup |      10.00 | medio

Un producto de 10 euros clasificado como "medio". El segundo WHEN es inalcanzable: todo lo que vale menos de 20 vale también menos de 50, así que el primero se lo lleva siempre. No hay error, no hay aviso — solo una categoría que nunca aparece.

Regla: en un CASE de rangos, escribe los WHEN de más restrictivo a menos. Y comprueba que todas las categorías aparecen en el resultado; si una no sale nunca, probablemente es inalcanzable.

3.3 CASE en el GROUP BY

Como el CASE produce una columna, se puede agrupar por ella:

SELECT CASE WHEN unit_price < 20 THEN 'económico'
            WHEN unit_price < 50 THEN 'medio'
            ELSE 'premium'
       END                      AS gama,
       count(*)                 AS productos,
       round(avg(unit_price),2) AS precio_medio
FROM products
GROUP BY 1
ORDER BY 3;
   gama    | productos | precio_medio
-----------+-----------+--------------
 económico |        39 |        12.95
 medio     |        31 |        31.59
 premium   |         7 |       105.11

39 productos económicos, 31 medios y 7 premium. Ese GROUP BY 1 es el número de posición; también vale GROUP BY gama (el alias, que sí se acepta en GROUP BY — ver orden-logico-de-ejecucion). Lo que no vale es repetir el CASE entero: funciona, pero es doce líneas duplicadas que hay que mantener sincronizadas.

3.4 La forma simple

SELECT CASE extract(dow FROM order_date)
            WHEN 0 THEN 'domingo'  WHEN 1 THEN 'lunes'
            WHEN 2 THEN 'martes'   WHEN 3 THEN 'miércoles'
            WHEN 4 THEN 'jueves'   WHEN 5 THEN 'viernes'
            ELSE 'sábado'
       END      AS dia,
       count(*) AS pedidos
FROM orders
GROUP BY 1
ORDER BY 2 DESC;
    dia    | pedidos
-----------+---------
 martes    |     168
 jueves    |     168
 lunes     |     165
 miércoles |     165
 viernes   |     164

Cinco filas, no siete. Ni sábado ni domingo aparecen:

SELECT count(*) FROM orders WHERE extract(dow FROM order_date) IN (0, 6);   -- 0

Cero pedidos en fin de semana en toda la base. Es un hallazgo real sobre estos datos —Northwind registra pedidos solo en días laborables— y es el tipo de cosa que descubres solo cuando agrupas. Si alguien te pide una media diaria de pedidos, dividir entre 671 días naturales está mal: hay que dividir entre días hábiles.

3.5 CASE en el ORDER BY — orden a medida

Ordenar los cargos por jerarquía, que no es su orden alfabético:

SELECT title,
       CASE WHEN title LIKE '%Vice%'    THEN 0
            WHEN title LIKE '%Manager%' THEN 1
            ELSE 2
       END AS orden
FROM employees
ORDER BY orden, title;
          title           | orden
--------------------------+-------
 Vice President, Sales    |     0
 Sales Manager            |     1
 Inside Sales Coordinator |     2
 Sales Representative     |     2

El CASE puede ir directamente en el ORDER BY sin sacarlo al SELECT. Lo he sacado aquí solo para que se vea el número. En un informe real, ordenar por una columna invisible es incómodo de verificar; enseñarla suele compensar.

3.6 CASE en el WHERE

Es legal, y casi siempre innecesario:

-- Funciona, pero es una forma retorcida de escribir un OR
WHERE CASE WHEN discontinued THEN units_in_stock > 0 ELSE true END

El caso donde sí vale la pena es forzar el orden de evaluación para evitar un error:

WHERE CASE WHEN quantity = 0 THEN false ELSE total / quantity > 5 END

AND no garantiza que se evalúe de izquierda a derecha (where-y-operadores); CASE sí garantiza que no se evalúa un THEN cuyo WHEN falló. Es la forma de blindarse contra una división por cero. La otra es nullif(quantity, 0), más corta.

3.7 Pivotar: filas a columnas

Es el uso más vistoso de CASE. Pedidos por país y por año, en columnas:

SELECT ship_country AS pais,
       sum(CASE WHEN extract(year FROM order_date) = 1996 THEN 1 ELSE 0 END) AS y1996,
       sum(CASE WHEN extract(year FROM order_date) = 1997 THEN 1 ELSE 0 END) AS y1997,
       sum(CASE WHEN extract(year FROM order_date) = 1998 THEN 1 ELSE 0 END) AS y1998,
       count(*) AS total
FROM orders
GROUP BY 1
ORDER BY total DESC
LIMIT 6;
   pais    | y1996 | y1997 | y1998 | total
-----------+-------+-------+-------+-------
 Germany   |    24 |    64 |    34 |   122
 USA       |    23 |    60 |    39 |   122
 Brazil    |    13 |    42 |    28 |    83
 France    |    15 |    39 |    23 |    77
 UK        |    10 |    30 |    16 |    56
 Venezuela |     8 |    20 |    18 |    46

El patrón es siempre el mismo: sum(CASE WHEN <columna a pivotar> = <valor> THEN <métrica> ELSE 0 END) — una columna del resultado por cada valor.

PostgreSQL tiene una forma más limpia de escribir exactamente esto:

SELECT ship_country AS pais,
       count(*) FILTER (WHERE extract(year FROM order_date) = 1996) AS y1996,
       count(*) FILTER (WHERE extract(year FROM order_date) = 1997) AS y1997,
       count(*) FILTER (WHERE extract(year FROM order_date) = 1998) AS y1998,
       count(*) AS total
FROM orders GROUP BY 1 ORDER BY total DESC;

Mismo resultado, y se lee. FILTER es del estándar SQL pero no lo implementa SQL Server; se ve en agregacion-avanzada.

La limitación del pivote con CASE: hay que escribir una línea por valor, a mano. Si el año que viene aparece 1999, la consulta no lo muestra y no da error. Un pivote dinámico no se puede hacer en SQL puro — se resuelve en la herramienta de informes, o con crosstab (pivotar-datos).

3.8 CASE sin ELSE

SELECT product_name,
       CASE WHEN discontinued THEN 'baja' END AS estado
FROM products LIMIT 3;
 product_name  | estado
---------------+--------
 Chai          | baja
 Chang         | baja
 Aniseed Syrup |

Sin ELSE, lo que no encaja sale NULL. A veces es lo que quieres; muchas veces es un ELSE olvidado. Y como NULL se propaga, ese hueco puede reaparecer tres consultas más tarde convertido en una suma vacía (null-y-la-logica-de-tres-valores).

4. Errores comunes

  1. WHEN en mal orden. El caso de "medio" comiéndose a "económico". Ni error ni aviso.
  2. Olvidar el ELSE. Aparecen NULL donde esperabas una categoría.
  3. Mezclar tipos en los THEN. THEN 'sin datos' junto a THEN 0 da error de tipo.
  4. Comparar con NULL dentro de un WHEN. WHEN region = NULL nunca se cumple. Se escribe WHEN region IS NULL.
  5. Repetir el CASE entero en el GROUP BY. Usa el número de posición o el alias.
  6. Pivotar con CASE y olvidar actualizar la lista de valores. El dato nuevo desaparece del informe en silencio.
  7. Usar CASE donde basta coalesce. CASE WHEN x IS NULL THEN y ELSE x END es exactamente coalesce(x, y).

5. Diferencias con SQL Server

CASE es idéntico en los dos motores: mismas dos formas, mismas reglas, misma sintaxis. Es de lo poco que se copia y pega sin tocar nada.

Lo que cambia es lo que hay alrededor:

Necesidad SQL Server PostgreSQL
CASE Igual Igual
Condicional corto IIF(cond, a, b) ❌ No existe → CASE WHEN cond THEN a ELSE b END
Elegir de una lista CHOOSE(n, a, b, c) ❌ No existe
Agregar con filtro SUM(CASE WHEN ... THEN 1 ELSE 0 END) count(*) FILTER (WHERE ...)
Pivotar PIVOT / UNPIVOT (operador propio) ❌ No existe → CASE o crosstab

IIF y PIVOT son extensiones de SQL Server. PIVOT tampoco es dinámico: también hay que enumerar los valores a mano, así que la limitación es la misma con mejor sintaxis.

6. Ejercicios

  1. Clasifica los clientes por continente aproximado según su país: América, Europa, Otros. Cuenta cuántos hay de cada uno.
  2. Clasifica los productos por estado de stock: sin stock, bajo (por debajo del punto de reposición) y ok. ¿Cuántos hay de cada uno?
  3. Ordena los productos poniendo primero los descatalogados y, dentro de cada grupo, del más caro al más barato.
  4. Pivota los pedidos por año (filas) y trimestre (columnas).
  5. Reescribe esto sin CASE: sql SELECT company_name, CASE WHEN region IS NULL THEN 'n/a' ELSE region END FROM customers;

7. Pregunta de negocio

Dirección quiere un semáforo de inventario para la reunión del lunes: por categoría de producto, cuántos están en rojo, ámbar y verde. Rojo es sin stock, ámbar es por debajo del punto de reposición, verde el resto. Y quieren verlo de un vistazo, no una lista de 77 filas.

Todavía no sabes hacer JOIN, así que la categoría tendrá que ser su id. Eso también es información que hay que dar.

? RespuestaRespóndela antes de abrir
SELECT category_id,
       count(*) FILTER (WHERE units_in_stock = 0)                                        AS rojo,
       count(*) FILTER (WHERE units_in_stock > 0 AND units_in_stock < reorder_level)     AS ambar,
       count(*) FILTER (WHERE units_in_stock >= reorder_level)                           AS verde,
       count(*)                                                                          AS total
FROM products
WHERE NOT discontinued
GROUP BY category_id
ORDER BY rojo DESC, ambar DESC;
 category_id | rojo | ambar | verde | total
-------------+------+-------+-------+-------
           4 |    1 |     2 |     7 |    10
           3 |    0 |     4 |     9 |    13
           8 |    0 |     3 |     9 |    12
           1 |    0 |     2 |     7 |     9
           5 |    0 |     2 |     4 |     6
           2 |    0 |     2 |     9 |    11
           7 |    0 |     1 |     3 |     4
           6 |    0 |     0 |     2 |     2

Ocho filas en vez de 77. Eso era la mitad del encargo: "de un vistazo" significa agregar.

Y las tres cosas que van dichas al entregarlo:

  1. Las categorías salen como número porque todavía no hemos unido tablas. category_id = 4 es "Dairy Products", y el nombre está en categories. Con un JOIN la tabla se lee sola (inner-join). Entregar ids a Dirección es entregar la mitad del trabajo.
  2. He filtrado los descatalogados. Un producto que ya no se vende y está a cero no es una alerta roja, es lo normal. El filtro cambia mucho el cuadro: con toda la tabla hay 5 productos sin stock, y con NOT discontinued queda 1. Los otros cuatro son ruido que habría pintado de rojo cuatro categorías. Es una decisión, y se cuenta.
  3. El ámbar necesita units_in_stock > 0 explícito. Aquí, a diferencia del CASE del ejercicio 2, cada FILTER se evalúa por separado: no hay "el anterior ya se lo llevó". Sin esa condición, los productos a cero contarían en rojo y en ámbar, y las columnas no sumarían el total. Cuando pivotas con FILTER, comprueba siempre que las columnas suman.
✓ SolucionesResuélvelos antes de abrir

1.

SELECT CASE WHEN country IN ('USA','Canada','Mexico','Brazil','Argentina','Venezuela') THEN 'América'
            WHEN country IN ('Germany','France','UK','Spain','Italy','Portugal','Sweden',
                             'Norway','Denmark','Finland','Austria','Switzerland',
                             'Belgium','Ireland','Poland')                              THEN 'Europa'
            ELSE 'Otros'
       END      AS continente,
       count(*) AS clientes
FROM customers
GROUP BY 1
ORDER BY 2 DESC;
 continente | clientes
------------+----------
 Europa     |       54
 América    |       37

Solo dos filas: Otros no aparece. Y eso no se da por bueno, se comprueba — el ELSE es donde se esconden los países que clasificaste mal:

SELECT DISTINCT country FROM customers
WHERE country NOT IN ('USA','Canada','Mexico','Brazil','Argentina','Venezuela',
                      'Germany','France','UK','Spain','Italy','Portugal','Sweden',
                      'Norway','Denmark','Finland','Austria','Switzerland',
                      'Belgium','Ireland','Poland');

2.

SELECT CASE WHEN units_in_stock = 0             THEN 'sin stock'
            WHEN units_in_stock < reorder_level THEN 'bajo'
            ELSE 'ok'
       END      AS estado,
       count(*) AS productos
FROM products
GROUP BY 1
ORDER BY 2 DESC;
  estado   | productos
-----------+-----------
 ok        |        55
 bajo      |        17
 sin stock |         5

El orden de los WHEN importa: sin stock tiene que ir primero, porque un producto con 0 unidades también cumple < reorder_level y saldría como bajo.

3.

SELECT product_name, discontinued, unit_price
FROM products
ORDER BY CASE WHEN discontinued THEN 0 ELSE 1 END, unit_price DESC;

Con un boolean hay un atajo: ORDER BY discontinued DESC, unit_price DESC hace lo mismo, porque true ordena después de false. El CASE sigue siendo la respuesta buena en cuanto el criterio no sea un booleano — por ejemplo, poner primero un estado concreto de entre cinco.

4.

SELECT extract(year FROM order_date)::int AS anio,
       count(*) FILTER (WHERE extract(quarter FROM order_date) = 1) AS q1,
       count(*) FILTER (WHERE extract(quarter FROM order_date) = 2) AS q2,
       count(*) FILTER (WHERE extract(quarter FROM order_date) = 3) AS q3,
       count(*) FILTER (WHERE extract(quarter FROM order_date) = 4) AS q4,
       count(*) AS total
FROM orders
GROUP BY 1
ORDER BY 1;
 anio | q1  | q2 | q3  | q4  | total
------+-----+----+-----+-----+-------
 1996 |   0 |  0 |  70 |  82 |   152
 1997 |  92 | 93 | 103 | 120 |   408
 1998 | 182 | 88 |   0 |   0 |   270

Los ceros de las esquinas son la parte informativa: la base empieza en julio de 1996 y termina en mayo de 1998. Un gráfico de esta tabla sin esa advertencia hace pensar que el negocio se hundió en 1998.

5.

SELECT company_name, coalesce(region, 'n/a') FROM customers;

CASE WHEN x IS NULL THEN y ELSE x END es coalesce(x, y). Más corto, más rápido de leer y no se puede escribir al revés por error.