SQL · Guías · 29 de octubre de 2026 · 12 min de lectura

Ejercicios SQL resueltos: 15 ejercicios de fácil a difícil con solución

Un cuaderno con una escalera de tarjetas de ejercicios marcadas en verde junto a un portátil con una tabla

Estos son 15 ejercicios SQL resueltos, ordenados de fácil a difícil: cinco de nivel básico (SELECT, WHERE, ORDER BY, COUNT), cinco intermedios (GROUP BY, HAVING, JOIN, CASE, fechas) y cinco avanzados (subconsultas, varias tablas, funciones de ventana). Cada uno tiene el enunciado, la solución, el resultado real y una explicación de la idea que practica. Intenta resolverlos antes de mirar la respuesta: la habilidad que se entrena es traducir una pregunta de negocio a una consulta, no memorizar sintaxis.

Todas las soluciones están escritas en SQL compatible con PostgreSQL y se ejecutan sobre la base de datos de una aerolínea que usamos en el curso de SQL de Nomto. Puedes copiarlas, modificarlas y comprobar tu propia versión en la sala de SQL de Nomto, con los mismos datos.

Los datos

Tabla Una fila es Columnas principales
airports un aeropuerto airport_code, airport_name, city, timezone
aircrafts un modelo de avión aircraft_code, model, range (km)
seats un asiento de la cabina de un modelo aircraft_code, seat_no, fare_conditions
flights una salida de un vuelo en un día flight_id, flight_no, scheduled_departure, scheduled_arrival, departure_airport, arrival_airport, status, aircraft_code
bookings una reserva book_ref, book_date, total_amount
tickets un billete de un pasajero ticket_no, book_ref, passenger_id, passenger_name
ticket_flights un tramo de vuelo en un billete ticket_no, flight_id, fare_conditions, amount
boarding_passes una tarjeta de embarque ticket_no, flight_id, seat_no

Los valores de texto están en inglés ('Moscow', 'Economy', 'Cancelled'), el dinero en dólares y las horas en la hora de la aerolínea (Moscú, UTC+3). Los datos se extrajeron el 11 de agosto de 2026 a las 18:00: los vuelos anteriores ya ocurrieron y los posteriores son solo horario.

Nivel fácil

Ejercicio 1. ¿Cuántos aeropuertos y cuántas ciudades hay?

SELECT COUNT(*)             AS airports,
       COUNT(DISTINCT city) AS cities
FROM airports;
airports cities
104 101

Explicación. COUNT(*) cuenta filas; COUNT(DISTINCT city) cuenta valores distintos. La diferencia (104 frente a 101) ya dice algo: hay ciudades con más de un aeropuerto (Moscú tiene tres).

Ejercicio 2. Modelos de avión con un alcance superior a 5000 km, del mayor al menor

SELECT model, range
FROM aircrafts
WHERE range > 5000
ORDER BY range DESC;
model range
Boeing 777-300 11100
Boeing 767-300 7900
Airbus A319-100 6700
Airbus A320-200 5700
Airbus A321-200 5600

Explicación. WHERE filtra y ORDER BY ... DESC ordena de mayor a menor. Pregunta trampa: ¿«superior a 5000» incluye 5000? No, por eso > y no >=. En una entrevista, aclara siempre los límites.

Ejercicio 3. Aeropuertos que están en el huso horario de Yakutsk

SELECT city, airport_name, timezone
FROM airports
WHERE timezone = 'Asia/Yakutsk'
ORDER BY city;
city airport_name timezone
Blagoveshchensk Ignatyevo Asia/Yakutsk
Mirny Mirny Asia/Yakutsk
Neryungri Chulman Asia/Yakutsk
Udachny Polyarny Asia/Yakutsk
Yakutsk Yakutsk Asia/Yakutsk

Explicación. Comparación de texto exacta, con comillas simples y respetando mayúsculas. 'asia/yakutsk' no devolvería nada.

Ejercicio 4. ¿Cuántos vuelos están cancelados y entre qué fechas?

SELECT COUNT(*)                 AS cancelled,
       MIN(scheduled_departure) AS first,
       MAX(scheduled_departure) AS last
FROM flights
WHERE status = 'Cancelled';
cancelled first last
414 2026-07-15 11:35:00 2026-09-10 20:55:00

Explicación. Varios agregados en una sola consulta. MIN y MAX funcionan con fechas igual que con números. La mayoría de las cancelaciones están en septiembre, es decir, en el horario futuro.

Ejercicio 5. Número de vuelos por estado

SELECT status, COUNT(*) AS flights
FROM flights
GROUP BY status
ORDER BY flights DESC;
status flights
Arrived 16707
Scheduled 15383
On Time 518
Cancelled 414
Departed 58
Delayed 41

Explicación. El primer GROUP BY. Antes de analizar cualquier columna de estado, haz esta consulta: te dice qué valores existen exactamente y cuántas filas tiene cada uno.

Nivel intermedio

Ejercicio 6. Los cinco nombres de pasajero más frecuentes

SELECT passenger_name, COUNT(*) AS tickets
FROM tickets
GROUP BY passenger_name
ORDER BY tickets DESC, passenger_name
LIMIT 5;
passenger_name tickets
ALEKSANDR IVANOV 842
ALEKSANDR KUZNECOV 755
SERGEY IVANOV 634
SERGEY KUZNECOV 569
VLADIMIR IVANOV 551

Explicación. El resultado no significa que Aleksandr Ivanov haya volado 842 veces: son muchas personas distintas con el mismo nombre. En tickets hay 366 733 valores distintos de passenger_id y solo 22 220 nombres distintos. Lección: un nombre no es una clave. Para contar personas usa el identificador.

Ejercicio 7. ¿Cuántas reservas tienen 1, 2, 3… billetes?

SELECT tickets, COUNT(*) AS bookings
FROM (
  SELECT book_ref, COUNT(*) AS tickets
  FROM tickets
  GROUP BY book_ref
) t
GROUP BY tickets
ORDER BY tickets;
tickets bookings
1 173390
2 75793
3 12686
4 896
5 23

Explicación. Una agregación sobre otra agregación: primero billetes por reserva (subconsulta en FROM) y luego reservas por número de billetes. Este patrón, «distribución de un conteo», aparece constantemente en análisis de producto (pedidos por cliente, sesiones por usuario).

Ejercicio 8. Reservas por día de la semana en julio

SELECT EXTRACT(ISODOW FROM book_date) AS dow,
       COUNT(*)                       AS bookings,
       ROUND(AVG(total_amount), 2)    AS avg_amount
FROM bookings
WHERE book_date >= '2026-07-01' AND book_date < '2026-08-01'
GROUP BY EXTRACT(ISODOW FROM book_date)
ORDER BY dow;
dow bookings avg_amount
1 21953 794.99
2 22392 795.58
3 27477 792.21
4 27551 787.08
5 28014 799.38
6 21864 788.13
7 22395 787.14

Explicación. ISODOW numera de 1 (lunes) a 7 (domingo). ¿Los miércoles, jueves y viernes se reserva más? No necesariamente: julio de 2026 empieza en miércoles, así que tiene cinco miércoles, jueves y viernes y solo cuatro de los demás días. Para comparar días de la semana, divide entre el número de días de cada tipo o usa el promedio diario. El importe medio, en cambio, sí es comparable y casi no cambia.

Ejercicio 9. Reparte las reservas por tramos de importe

SELECT CASE
         WHEN total_amount < 200  THEN '1: < 200'
         WHEN total_amount < 500  THEN '2: 200-499'
         WHEN total_amount < 1000 THEN '3: 500-999'
         ELSE '4: 1000+'
       END AS bucket,
       COUNT(*) AS bookings,
       ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct
FROM bookings
GROUP BY 1
ORDER BY 1;
bucket bookings pct
1: < 200 27740 10.6
2: 200-499 90566 34.5
3: 500-999 80008 30.4
4: 1000+ 64474 24.5

Explicación. CASE evalúa las condiciones en orden y se queda con la primera verdadera, por eso basta con < 500 en la segunda rama. El prefijo numérico en la etiqueta hace que el orden alfabético coincida con el lógico. El porcentaje usa una ventana sobre todo el resultado; si aún no las conoces, mira funciones de ventana SQL.

Ejercicio 10. Duración media programada del vuelo por modelo de avión

SELECT a.model,
       COUNT(*) AS flights,
       ROUND(AVG(EXTRACT(EPOCH FROM f.scheduled_arrival - f.scheduled_departure) / 60)) AS avg_minutes
FROM flights f
JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
GROUP BY a.model
ORDER BY avg_minutes DESC;
model flights avg_minutes
Airbus A319-100 1239 342
Boeing 767-300 1221 246
Boeing 737-300 1274 143
Boeing 777-300 610 141
Cessna 208 Caravan 9273 140
Bombardier CRJ-200 9048 119
Sukhoi Superjet-100 8504 87
Airbus A321-200 1952 73

Explicación. JOIN para traer el nombre del modelo, resta de fechas para obtener un intervalo y EXTRACT(EPOCH ...) para pasarlo a segundos. Solo salen 8 modelos aunque aircrafts tiene 9: el Airbus A320-200 no tiene vuelos y el INNER JOIN lo descarta. Si necesitas que aparezca con 0, usa LEFT JOIN desde aircrafts.

Nivel difícil

Ejercicio 11. Las cinco rutas entre ciudades con más ingresos

SELECT dep.city    AS from_city,
       arr.city    AS to_city,
       COUNT(*)    AS segments,
       SUM(tf.amount) AS revenue
FROM ticket_flights tf
JOIN flights f    ON f.flight_id = tf.flight_id
JOIN airports dep ON dep.airport_code = f.departure_airport
JOIN airports arr ON arr.airport_code = f.arrival_airport
GROUP BY dep.city, arr.city
ORDER BY revenue DESC
LIMIT 5;
from_city to_city segments revenue
Moscow Novosibirsk 29063 10063493.00
Novosibirsk Moscow 28239 9775549.00
Moscow Khabarovsk 9647 7534783.00
Khabarovsk Moscow 9366 7337978.00
Khabarovsk St. Petersburg 6435 5076724.00

Explicación. La misma tabla airports entra dos veces, con alias distintos: una para el origen y otra para el destino. Agrupamos por ciudad y no por aeropuerto, así que los tres aeropuertos de Moscú cuentan como uno. Los ingresos salen de ticket_flights, que tiene el precio de cada tramo; sumar bookings.total_amount aquí sería un error, porque una reserva puede tener varios tramos en rutas distintas.

Ejercicio 12. ¿Qué parte de las reservas supera el importe medio?

SELECT COUNT(*) AS above_avg,
       ROUND(100.0 * COUNT(*) / (SELECT COUNT(*) FROM bookings), 1) AS pct
FROM bookings
WHERE total_amount > (SELECT AVG(total_amount) FROM bookings);
above_avg pct
87224 33.2

Explicación. Subconsultas escalares: devuelven un solo valor y se pueden usar como un número más. El importe medio es 790.26, pero solo un tercio de las reservas lo supera. Eso indica una distribución sesgada: unas pocas reservas muy caras suben la media. La mediana (PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total_amount)) es 559. Con datos de dinero, mira siempre las dos.

Ejercicio 13. El segundo importe de reserva más alto

SELECT MAX(total_amount)
FROM bookings
WHERE total_amount < (SELECT MAX(total_amount) FROM bookings);

Resultado: 10871.00.

Explicación. Clásico de entrevista. La versión general, para el N-ésimo valor, usa DENSE_RANK:

SELECT DISTINCT total_amount
FROM (
  SELECT total_amount,
         DENSE_RANK() OVER (ORDER BY total_amount DESC) AS rnk
  FROM bookings
) t
WHERE rnk = 2;

Devuelve el mismo 10871.00. Se usa DENSE_RANK y no ROW_NUMBER para que, si el máximo estuviera repetido, el «segundo» sea el siguiente valor distinto y no una copia del primero. Aclara este matiz con quien te haga la pregunta.

Ejercicio 14. Variación semanal de reservas

Reservas por semana completa (de lunes a domingo) y su cambio porcentual respecto a la semana anterior:

WITH w AS (
  SELECT DATE_TRUNC('week', book_date) AS week,
         COUNT(*) AS bookings
  FROM bookings
  WHERE book_date >= '2026-06-29' AND book_date < '2026-08-10'
  GROUP BY DATE_TRUNC('week', book_date)
)
SELECT CAST(week AS DATE) AS week,
       bookings,
       LAG(bookings) OVER (ORDER BY week) AS prev_week,
       ROUND(100.0 * (bookings - LAG(bookings) OVER (ORDER BY week))
             / LAG(bookings) OVER (ORDER BY week), 1) AS change_pct
FROM w
ORDER BY week;
week bookings prev_week change_pct
2026-06-29 37467 null null
2026-07-06 39151 37467 4.5
2026-07-13 38490 39151 -1.7
2026-07-20 38728 38490 0.6
2026-07-27 39216 38728 1.3
2026-08-03 42561 39216 8.5

Explicación. DATE_TRUNC('week', ...) lleva cada fecha al lunes de su semana y LAG trae el valor de la fila anterior. El filtro excluye a propósito las semanas incompletas: los datos empiezan un miércoles (17 de junio) y terminan el 11 de agosto a las 18:00. Si incluyeras la semana del 10 de agosto, verías una «caída» del 71.5 % que solo es falta de datos.

Ejercicio 15. Ocupación media de los vuelos por modelo de avión

Para los vuelos aterrizados, qué porcentaje de los asientos se ocupó en promedio (pasajeros con tarjeta de embarque entre asientos del modelo):

WITH s AS (
  SELECT aircraft_code, COUNT(*) AS seats
  FROM seats
  GROUP BY aircraft_code
),
b AS (
  SELECT flight_id, COUNT(*) AS passengers
  FROM boarding_passes
  GROUP BY flight_id
)
SELECT a.model,
       COUNT(*) AS flights,
       ROUND(AVG(100.0 * COALESCE(b.passengers, 0) / s.seats), 1) AS avg_load_pct
FROM flights f
JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
JOIN s           ON s.aircraft_code = f.aircraft_code
LEFT JOIN b      ON b.flight_id = f.flight_id
WHERE f.status = 'Arrived'
GROUP BY a.model
ORDER BY avg_load_pct DESC;
model flights avg_load_pct
Boeing 777-300 307 65.8
Boeing 737-300 646 56.4
Boeing 767-300 612 50.2
Sukhoi Superjet-100 4292 48.1
Airbus A319-100 624 40.3
Bombardier CRJ-200 4570 36.5
Airbus A321-200 982 34.2
Cessna 208 Caravan 4674 14.4

Explicación. El ejercicio junta casi todo: dos CTE que agregan antes de unir, LEFT JOIN con COALESCE para que un vuelo sin embarques cuente como 0 % y no desaparezca, y un promedio de porcentajes por vuelo. Lo importante es el orden: si unieras flights con seats y boarding_passes sin agregar primero, cada pasajero se multiplicaría por cada asiento y los números serían absurdos. Agregar a la granularidad correcta antes del JOIN es la habilidad que separa a un analista junior de uno sólido.

Cómo sacar partido a estos ejercicios

En la sala de SQL de Nomto puedes ejecutar cada solución y probar tus variantes sobre la misma base de datos; las tareas del curso se corrigen automáticamente y te dicen qué falla (una columna de más, filas que sobran, un orden distinto). Si algún ejercicio de nivel fácil te costó, repasa primero las consultas SQL básicas; si los difíciles te salieron, sigue con las preguntas de entrevista SQL.

Preguntas frecuentes

¿Dónde puedo practicar ejercicios SQL online gratis?

En la sala de SQL de Nomto escribes y ejecutas consultas en el navegador sobre una base de datos real de una aerolínea, sin instalar nada. La teoría y los primeros módulos con tareas son gratuitos.

¿Estos ejercicios sirven para MySQL o SQL Server?

La lógica sí. Cambian algunas funciones: EXTRACT(EPOCH FROM ...) y DATE_TRUNC son de PostgreSQL; en MySQL se usan TIMESTAMPDIFF y DATE_FORMAT, y en SQL Server DATEDIFF y DATETRUNC.

¿Cuántos ejercicios necesito hacer para dominar SQL?

Más que el número importa la variedad. Con 50–100 ejercicios que cubran filtros, agregación, JOIN, subconsultas y funciones de ventana, la mayoría de las personas puede resolver tareas de analista junior.

¿Qué hago si mi resultado no coincide con la solución?

Compara por partes: ejecuta solo el FROM con los JOIN y cuenta filas, luego añade el WHERE, luego el GROUP BY. El paso en el que el número se separa del esperado es donde está el error.

También en Português (Brasil), Tiếng Việt, English, 简体中文