SQL · Guides · October 29, 2026 · 11 min read

SQL practice problems with answers: 15 tasks from easy to hard

A notebook with a checklist of ticked boxes next to a small airplane model and a laptop showing a data table

Below are 15 SQL practice problems on one database, ordered from easy to hard. Each has the question, the answer query under “Solution”, the real result and a line or two on why the solution works. Try each one before you read the answer: the point of practice is the minute you spend stuck, not the reading.

All problems use the airline database from the Nomto SQL course. It is a PostgreSQL-style schema with real volumes: 104 airports, 9 aircraft models, 33,121 flights from 12 July to 10 September 2026, 262,788 bookings and 366,733 tickets. Times are in the airline’s home time (Moscow, UTC+3); money is in US dollars. The data snapshot was taken on 11 August 2026 at 18:00, so flights before that moment have happened and later ones are only scheduled. You can run every query in the Nomto SQL room and compare your output with the tables here.

The tables you need

Table One row is Key columns
airports an airport airport_code, airport_name, city, timezone, longitude
aircrafts an aircraft model aircraft_code, model, range (km)
seats a seat in a model’s cabin aircraft_code, seat_no, fare_conditions
flights one departure flight_id, flight_no, scheduled_departure, scheduled_arrival, departure_airport, arrival_airport, status, aircraft_code, actual_departure, actual_arrival
bookings a booking book_ref, book_date, total_amount
tickets a passenger ticket ticket_no, book_ref, passenger_name
ticket_flights a ticket on one flight segment ticket_no, flight_id, fare_conditions, amount
boarding_passes a boarding pass for a segment ticket_no, flight_id, seat_no

Easy: SELECT, WHERE, ORDER BY

1. How many airports and how many cities are there?

Solution

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

COUNT(*) counts rows, COUNT(DISTINCT city) counts different values. Three fewer cities than airports means some cities have several airports (problem 6 finds them).

2. List the aircraft models with a range of at least 5,000 km, longest first

Solution

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

“At least” means >=. A common slip is >, which would give the same answer here only by luck.

3. The five easternmost airports outside Moscow time

Solution

SELECT airport_code, airport_name, city, timezone
FROM airports
WHERE timezone <> 'Europe/Moscow'
ORDER BY longitude DESC
LIMIT 5;
airport_code airport_name city timezone
DYR Anadyr Anadyr Asia/Anadyr
PKC Yelizovo Petropavlovsk-Kamchatsky Asia/Kamchatka
GDX Magadan Magadan Asia/Magadan
UUS Khomutovo Yuzhno-Sakhalinsk Asia/Sakhalin
KXK Khurba Komsomolsk-on-Amur Asia/Vladivostok

“Easternmost” is the largest longitude. 60 of the 104 airports are outside Moscow time.

Easy to medium: GROUP BY and HAVING

4. How many flights depart on each day of the week?

Use four full weeks, 13 July to 9 August, so every weekday appears the same number of times.

Solution

SELECT EXTRACT(ISODOW FROM scheduled_departure) AS weekday, COUNT(*) AS flights
FROM flights
WHERE scheduled_departure >= '2026-07-13'
  AND scheduled_departure <  '2026-08-10'
GROUP BY EXTRACT(ISODOW FROM scheduled_departure)
ORDER BY weekday;
weekday flights
1 2144
2 2180
3 2192
4 2200
5 1972
6 2272
7 2232

ISODOW numbers Monday as 1 and Sunday as 7. Saturday is the busiest day, Friday the quietest. Comparing weekdays over a period that is not a whole number of weeks would give some days an extra week of flights.

5. The five busiest routes by number of flights

Solution

SELECT departure_airport, arrival_airport, COUNT(*) AS flights
FROM flights
GROUP BY departure_airport, arrival_airport
ORDER BY flights DESC, departure_airport, arrival_airport
LIMIT 5;
departure_airport arrival_airport flights
LED SVO 305
SVO LED 305
DME LED 244
LED DME 244
BZK DME 183

A route is a pair of airports, so both go in GROUP BY. The extra sort columns make the order stable when counts tie.

6. Which cities have more than one airport?

Solution

SELECT city, COUNT(*) AS airports
FROM airports
GROUP BY city
HAVING COUNT(*) > 1
ORDER BY airports DESC;
city airports
Moscow 3
Ulyanovsk 2

The condition is on a count, so it belongs in HAVING, not WHERE. More on that in GROUP BY vs HAVING.

7. Which aircraft models have more than 200 seats?

Solution

SELECT a.model, COUNT(*) AS seats
FROM aircrafts a
JOIN seats s ON s.aircraft_code = a.aircraft_code
GROUP BY a.model
HAVING COUNT(*) > 200
ORDER BY seats DESC;
model seats
Boeing 777-300 402
Boeing 767-300 222

seats has one row per seat, so counting rows per model gives cabin size; the join only adds the readable model name.

8. Revenue, segments and average price by class

Solution

SELECT fare_conditions,
       COUNT(*) AS segments,
       SUM(amount) AS revenue,
       ROUND(AVG(amount), 2) AS avg_price
FROM ticket_flights
GROUP BY fare_conditions
ORDER BY revenue DESC;
fare_conditions segments revenue avg_price
Economy 920793 146956844.00 159.6
Business 107642 55051796.00 511.43
Comfort 17291 5661169.00 327.41

Revenue per class lives in ticket_flights, one row per passenger per segment. Summing bookings.total_amount after joining it to segments would count each booking many times.

Medium: dates, conditions, subqueries

9. The longest scheduled flights

Solution

SELECT DISTINCT flight_no, departure_airport, arrival_airport,
       EXTRACT(EPOCH FROM scheduled_arrival - scheduled_departure) / 60 AS minutes
FROM flights
ORDER BY minutes DESC, flight_no
LIMIT 3;
flight_no departure_airport arrival_airport minutes
PG0144 DME PKC 530
PG0145 PKC DME 530
PG0168 DME UUS 530

Subtracting timestamps gives an interval; EXTRACT(EPOCH FROM ...) turns it into seconds. Without DISTINCT, the same flight number would fill the top rows once for every day it flies. Several flights tie at 8 h 50 min, so LIMIT 3 shows only some of them.

10. What share of arrived flights landed more than an hour late?

Solution

SELECT COUNT(*) AS arrived,
       COUNT(*) FILTER (WHERE actual_arrival - scheduled_arrival > INTERVAL '1 hour') AS late_1h,
       ROUND(100.0 * COUNT(*) FILTER (WHERE actual_arrival - scheduled_arrival > INTERVAL '1 hour')
             / COUNT(*), 2) AS pct
FROM flights
WHERE status = 'Arrived';
arrived late_1h pct
16707 796 4.76

FILTER (WHERE ...) counts only rows that match, in the same pass as the total. SUM(CASE WHEN ... THEN 1 ELSE 0 END) does the same in databases without FILTER. Multiply by 100.0, not 100, to avoid integer division.

11. How many bookings are above the average booking amount?

Solution

SELECT COUNT(*) AS above_avg
FROM bookings
WHERE total_amount > (SELECT AVG(total_amount) FROM bookings);
above_avg
87224

The subquery returns one number (the average is $790.26), which the outer WHERE compares with each row. Only about a third of the 262,788 bookings are above average: a few large bookings pull the mean up.

12. The three routes with the highest ticket revenue

Solution

SELECT f.departure_airport, f.arrival_airport, SUM(tf.amount) AS revenue
FROM ticket_flights tf
JOIN flights f ON f.flight_id = tf.flight_id
GROUP BY f.departure_airport, f.arrival_airport
ORDER BY revenue DESC
LIMIT 3;
departure_airport arrival_airport revenue
DME KHV 7534783.00
KHV DME 7337978.00
DME OVB 5482189.00

Each segment belongs to one flight, so this join never multiplies rows: the sum stays at segment grain. Moscow Domodedovo to Khabarovsk earns the most, not the most frequent route from problem 5. Frequency and money are different rankings.

Hard: joins at the right grain and window functions

13. Seat load factor by aircraft model on arrived flights

Load factor = boarded passengers / seats offered.

Solution

WITH cap AS (
  SELECT aircraft_code, COUNT(*) AS seats
  FROM seats
  GROUP BY aircraft_code
), bp AS (
  SELECT flight_id, COUNT(*) AS boarded
  FROM boarding_passes
  GROUP BY flight_id
)
SELECT f.aircraft_code,
       COUNT(*) AS flights,
       ROUND(100.0 * SUM(COALESCE(bp.boarded, 0)) / SUM(cap.seats), 1) AS load_pct
FROM flights f
JOIN cap ON cap.aircraft_code = f.aircraft_code
LEFT JOIN bp ON bp.flight_id = f.flight_id
WHERE f.status = 'Arrived'
GROUP BY f.aircraft_code
ORDER BY load_pct DESC;
aircraft_code flights load_pct
773 307 65.8
733 646 56.4
763 612 50.2
SU9 4292 48.1
319 624 40.3
CR2 4570 36.5
321 982 34.2
CN1 4674 14.4

Both seats and boarding passes are aggregated to one row per model and per flight before joining. Joining raw seats and raw boarding_passes to flights would multiply them by each other. LEFT JOIN plus COALESCE keeps flights with nobody on board as zeros instead of dropping them. The SQL joins guide explains this fan-out in detail.

14. The top-earning route for each aircraft model

Solution

WITH r AS (
  SELECT f.aircraft_code, f.departure_airport, f.arrival_airport,
         SUM(tf.amount) AS revenue,
         ROW_NUMBER() OVER (PARTITION BY f.aircraft_code ORDER BY SUM(tf.amount) DESC) AS rn
  FROM ticket_flights tf
  JOIN flights f ON f.flight_id = tf.flight_id
  GROUP BY f.aircraft_code, f.departure_airport, f.arrival_airport
)
SELECT aircraft_code, departure_airport, arrival_airport, revenue
FROM r
WHERE rn = 1
ORDER BY revenue DESC;
aircraft_code departure_airport arrival_airport revenue
763 DME KHV 7534783.00
773 DME OVB 5482189.00
321 LED IKT 4116030.00
319 SVO UUS 2365732.00
733 SVO MJZ 1494478.00
SU9 DME NSK 1347231.00
CR2 DME NJC 449695.00
CN1 PEE ARH 50600.00

The window runs after GROUP BY, so it can rank by SUM(tf.amount). PARTITION BY restarts the numbering for each model, and the filter rn = 1 has to sit outside, because a window function cannot be used in WHERE. See SQL window functions for RANK and DENSE_RANK alternatives when ties matter.

15. Week-over-week change in bookings, and the trap in the last week

Solution

WITH w AS (
  SELECT DATE_TRUNC('week', book_date) AS week, COUNT(*) AS bookings
  FROM bookings
  WHERE book_date >= '2026-07-13'
  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-07-13 38490 NULL NULL
2026-07-20 38728 38490 0.6
2026-07-27 39216 38728 1.3
2026-08-03 42561 39216 8.5
2026-08-10 12122 42561 -71.5

DATE_TRUNC('week', ...) maps each timestamp to its Monday; LAG brings the previous week onto the current row. The query is correct, but the last line is not a 71.5% collapse: the data ends on Tuesday 11 August at 18:00, so that week contains less than two days. Before reporting a change, check that both periods are complete; here the honest answer is “+8.5% in the last full week”.

How to use these problems

If you want interview-style variants of these tasks, with model answers, see SQL interview questions for data analysts.

FAQ

What database are these SQL practice problems for?

They are written in PostgreSQL syntax on an airline database (flights, bookings, tickets, boarding passes) used in the Nomto SQL course. Apart from FILTER and ISODOW, the queries run with minor or no changes on most SQL databases.

My query gives the same result but looks different. Is it wrong?

Not necessarily. Many problems have several correct solutions, for example FILTER versus CASE, or a subquery versus a CTE. If your result matches on the full data, including edge cases such as ties and empty groups, the query is fine.

In what order should I practise SQL topics?

The order of this list works: filtering and sorting, then aggregates and GROUP BY/HAVING, then joins, then subqueries and CTEs, then window functions. Joins and aggregation together cause most real-world errors, so spend extra time on problems 8, 12 and 13.

Are these problems similar to SQL interview tasks?

Problems 6 and 9–15 are close to what analyst interviews ask: grouping with conditions, joins without double counting, top N per group and period-over-period change.

Also in Português (Brasil), Tiếng Việt, Español, 简体中文