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

Basic SQL queries: SELECT, WHERE, ORDER BY, GROUP BY and JOIN for beginners

A person at a desk builds a query from blocks that filter a large table down to a small, sorted one

A basic SQL query always has the same shape: SELECT says which columns you want to see, FROM says which table they come from, WHERE filters rows, GROUP BY groups them so you can compute totals, ORDER BY sorts the result and JOIN combines two tables on a shared column. These six pieces cover most of an analyst’s daily work. This guide walks through them one at a time, with queries that actually run: every one of them runs on the airline database from the Nomto SQL course, and each result table below is the real output.

The database in the examples

You will work with five tables from an airline (values are in English, money is in US dollars, times are Moscow time, UTC+3):

Table One row is Columns we will use
airports an airport (104) airport_code, airport_name, city, timezone
aircrafts an aircraft model (9) aircraft_code, model, range (in km)
flights one departure of a flight on one day (33,121) flight_no, scheduled_departure, departure_airport, arrival_airport, status, aircraft_code, actual_departure
bookings a booking book_ref, book_date, total_amount
ticket_flights one flight segment on a ticket flight_id, fare_conditions (cabin class), amount (price)

Before you write any query, know what one row of each table represents. Almost every beginner mistake in SQL comes from counting rows that are not what you think they are.

SELECT and FROM: picking columns

The simplest query asks for columns from a table:

SELECT model, range
FROM aircrafts;

A few rules worth settling early:

Range is in kilometres. To show it in miles as well:

SELECT model,
       range,
       ROUND(range / 1.609) AS range_miles
FROM aircrafts
ORDER BY range DESC
LIMIT 5;
model range range_miles
Boeing 777-300 11100 6899
Boeing 767-300 7900 4910
Airbus A319-100 6700 4164
Airbus A320-200 5700 3543
Airbus A321-200 5600 3480

Without ROUND the column would come out with a long tail of decimals (6898.69484151647). ORDER BY and LIMIT are covered further down.

WHERE: filtering rows

WHERE lets through only the rows that meet a condition. The airports in Moscow:

SELECT airport_code, airport_name, city
FROM airports
WHERE city = 'Moscow'
ORDER BY airport_code;
airport_code airport_name city
DME Domodedovo Moscow
SVO Sheremetyevo Moscow
VKO Vnukovo Moscow

Notice two details: text goes in single quotes (in PostgreSQL, double quotes are for column names), and the comparison is case-sensitive: 'moscow' returns nothing.

Combining conditions: AND, OR, IN, BETWEEN

Departures from Sheremetyevo on 1 August:

SELECT flight_no, scheduled_departure, arrival_airport, status
FROM flights
WHERE departure_airport = 'SVO'
  AND scheduled_departure >= '2026-08-01'
  AND scheduled_departure <  '2026-08-02'
ORDER BY scheduled_departure, flight_no
LIMIT 5;
flight_no scheduled_departure arrival_airport status
PG0120 2026-08-01 09:00:00 MJZ Arrived
PG0591 2026-08-01 09:00:00 TOF Arrived
PG0241 2026-08-01 09:40:00 CSY Arrived
PG0677 2026-08-01 09:40:00 MCX Arrived
PG0041 2026-08-01 09:50:00 NFG Arrived

That day 50 flights left SVO. The date filter uses the pattern “greater than or equal to the start of the day, and less than the start of the next day”. It is safer than BETWEEN '2026-08-01' AND '2026-08-01', which on a date-and-time column only catches the exact moment of midnight.

IN replaces several ORs on the same column. Flights that have already taken off (in the air or landed):

SELECT COUNT(*)
FROM flights
WHERE status IN ('Departed', 'Arrived');

The result is 16,765. When you mix AND and OR, use parentheses: AND is evaluated first, so a OR b AND c means a OR (b AND c).

LIKE: matching a pattern

% stands for any number of characters and _ for exactly one. Cities that start with “Novo”:

SELECT airport_code, airport_name, city
FROM airports
WHERE city LIKE 'Novo%'
ORDER BY city;
airport_code airport_name city
NOZ Spichenkovo Novokuznetsk
OVB Tolmachevo Novosibirsk

In PostgreSQL, ILIKE does the same without caring about case.

IS NULL: empty values

NULL means “no value”. A flight that has not left yet has an empty actual_departure. Here is the most famous trap in SQL:

SELECT COUNT(*) FROM flights WHERE actual_departure = NULL;   -- 0
SELECT COUNT(*) FROM flights WHERE actual_departure IS NULL;  -- 16348

The first returns 0 even though thousands of flights have not departed: any comparison with NULL gives “unknown”, and WHERE only lets through what is true. For empty values always use IS NULL or IS NOT NULL.

ORDER BY and LIMIT: sorting and keeping the top rows

Without ORDER BY the database returns rows in whatever order is convenient for it, and that order can change between runs. The five most expensive bookings:

SELECT book_ref, book_date, total_amount
FROM bookings
ORDER BY total_amount DESC
LIMIT 5;
book_ref book_date total_amount
3B54BB 2026-07-01 17:08:00 12045.00
3AC131 2026-07-27 01:06:00 10871.00
65A6EA 2026-06-29 06:28:00 10656.00
D7E9AA 2026-08-04 05:29:00 10628.00
EF479E 2026-07-29 15:58:00 10351.00

Aggregate functions: COUNT, SUM, AVG, MIN, MAX

Aggregate functions turn many rows into a single number. One difference worth understanding early:

SELECT COUNT(*)                           AS total,
       COUNT(actual_departure)            AS departed,
       COUNT(*) - COUNT(actual_departure) AS not_yet
FROM flights;
total departed not_yet
33121 16773 16348

COUNT(*) counts rows; COUNT(column) counts only non-empty values. COUNT(DISTINCT column) counts distinct values: SELECT COUNT(DISTINCT timezone) FROM airports returns 17 time zones.

GROUP BY: one total per group

GROUP BY splits the table into groups and computes the aggregate inside each one. How many flights are in each status:

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

Several aggregates at once, by cabin class:

SELECT fare_conditions,
       COUNT(*)              AS segments,
       ROUND(AVG(amount), 2) AS avg_price,
       MIN(amount)           AS min_price,
       MAX(amount)           AS max_price
FROM ticket_flights
GROUP BY fare_conditions
ORDER BY avg_price DESC;
fare_conditions segments avg_price min_price max_price
Business 107642 511.43 91.00 2033.00
Comfort 17291 327.41 199.00 476.00
Economy 920793 159.6 30.00 745.00

The golden rule: every column in SELECT must either be in GROUP BY or sit inside an aggregate function. If you write SELECT status, flight_no, COUNT(*) ... GROUP BY status, you get an error, because each group holds hundreds of flight numbers and the database does not know which one to show.

HAVING: filtering groups

WHERE filters rows before grouping; HAVING filters groups after. Cities with more than one airport:

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

You cannot write WHERE COUNT(*) > 1: when WHERE runs, the groups do not exist yet. The GROUP BY vs HAVING guide goes deeper into this.

JOIN: combining tables

In flights the aircraft appears only as a code (321, SU9). The model name lives in aircrafts. JOIN connects the two tables through the shared column:

SELECT a.model, COUNT(*) AS flights
FROM flights f
JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
GROUP BY a.model
ORDER BY flights DESC;
model flights
Cessna 208 Caravan 9273
Bombardier CRJ-200 9048
Sukhoi Superjet-100 8504
Airbus A321-200 1952
Boeing 737-300 1274
Airbus A319-100 1239
Boeing 767-300 1221
Boeing 777-300 610

f and a are aliases: short names so you do not have to repeat the full table name. ON says which rows belong together.

INNER JOIN and LEFT JOIN

The table above has 8 models, but aircrafts has 9. JOIN (short for INNER JOIN) keeps only rows that have a match in both tables, so the model with no flights disappears. LEFT JOIN keeps every row of the left table and fills in NULL where it finds no match. That is how you find “rows without a partner”:

SELECT a.aircraft_code, a.model
FROM aircrafts a
LEFT JOIN flights f ON f.aircraft_code = a.aircraft_code
WHERE f.flight_id IS NULL;
aircraft_code model
320 Airbus A320-200

If you count after a LEFT JOIN, use COUNT(f.flight_id), not COUNT(*): that way the A320 shows up with 0 instead of 1.

A warning for later: if you join a table to another one that has several rows for each of its rows (one booking with several tickets), rows multiply and sums inflate. The SQL joins guide shows this with real numbers.

Putting it together: a complete query

The pieces always go in a fixed order: SELECT … FROM … JOIN … WHERE … GROUP BY … HAVING … ORDER BY … LIMIT. Cities with at least 300 landed flights and their average departure delay in minutes:

SELECT ap.city,
       COUNT(*) AS flights,
       ROUND(AVG(EXTRACT(EPOCH FROM f.actual_departure - f.scheduled_departure) / 60), 1) AS avg_delay_min
FROM flights f
JOIN airports ap ON ap.airport_code = f.departure_airport
WHERE f.status = 'Arrived'
GROUP BY ap.city
HAVING COUNT(*) >= 300
ORDER BY avg_delay_min DESC;
city flights avg_delay_min
Yekaterinburg 348 14.7
Bryansk 306 13.2
Krasnoyarsk 358 13.1
St. Petersburg 956 12.6
Moscow 3990 12.4
Novosibirsk 536 11.8
Perm 311 10.5
Rostov-on-Don 310 9.2

Subtracting one timestamp from another gives an interval; EXTRACT(EPOCH FROM …) turns it into seconds, and dividing by 60 gives minutes.

The order in which a query runs

You write SELECT first, but the database processes it almost last:

  1. FROM and JOIN: build the working table.
  2. WHERE: drop rows.
  3. GROUP BY: form the groups.
  4. HAVING: drop groups.
  5. SELECT: compute the columns and aliases.
  6. ORDER BY: sort (here you can already use the aliases from SELECT).
  7. LIMIT: cut.

This order explains why a SELECT alias works in ORDER BY but not in WHERE, and why aggregates go in HAVING.

Common beginner mistakes

How to practise

Reading queries helps, but you learn by writing them and getting them wrong. Every example in this guide can be run and changed in the Nomto SQL room, on the same database: change the city, the status or the sort order and see what happens. The first module of the course starts with exactly SELECT and aliases, and every lesson ends with three tasks that are checked automatically. Once the basics feel solid, move on to the SQL practice problems and window functions.

FAQ

How long does it take to learn basic SQL queries?

You can learn SELECT, WHERE, ORDER BY, GROUP BY and JOIN in two or three weeks at an hour a day. What takes longer is reading the data correctly: knowing what one row means and when a JOIN multiplies rows.

What is the difference between WHERE and HAVING?

WHERE filters rows before grouping and cannot use aggregates. HAVING filters groups that have already been formed and is used with COUNT, SUM, AVG and the rest.

Does the SQL in this guide work in MySQL or SQL Server?

Almost all of it. SELECT, WHERE, GROUP BY, HAVING, JOIN and ORDER BY are the same everywhere. The details differ: SQL Server uses TOP instead of LIMIT, ILIKE does not exist in MySQL or SQL Server, and date functions have different names.

Do I need to install a database to practise?

No. In the Nomto SQL room you type the query in the browser and run it on the airline database, with nothing to install.

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