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

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
- Escribe antes de leer. Tapa la solución, escribe tu versión y solo entonces compárala. Si el resultado coincide pero la consulta es distinta, está bien: en SQL hay muchas formas correctas.
- Comprueba con números. ¿El total por grupos suma el total general? ¿El conteo tras un
JOINes el que esperabas? Esa costumbre detecta la mayoría de los errores. - Cambia un detalle. Otra ciudad, otra clase,
RANKen vez deROW_NUMBER. Ver qué cambia enseña más que el ejercicio original.
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, 简体中文