SQL · Guides · October 30, 2026 · 13 min read
SQL cheat sheet for data analysts: every clause and function with a runnable example

This is the SQL an analyst uses day to day, on one page: the clauses of a query, the filters, aggregates, joins, CASE, NULL handling, subqueries and CTEs, window functions, and the date and string functions you reach for most. Each section has a short reference table and one example with its real output, so you can see what the syntax actually returns rather than guess.
Every example runs on the airline database from the Nomto SQL course: 104 airports, 9 aircraft models, 33,121 flights from 12 July to 10 September 2026, 262,788 bookings, with money in US dollars and times in Moscow time. The syntax is PostgreSQL. You can paste any query into the Nomto SQL room and run it as is.
The tables used below
| Table | One row is | Columns used here |
|---|---|---|
airports |
an airport | airport_code, airport_name, city, timezone |
aircrafts |
an aircraft model | aircraft_code, model, range (km) |
flights |
one departure on one day | flight_id, flight_no, scheduled_departure, actual_departure, departure_airport, arrival_airport, status, aircraft_code |
bookings |
a booking | book_ref, book_date, total_amount |
tickets |
a passenger on a booking | ticket_no, book_ref, passenger_name, contact_data (JSON) |
ticket_flights |
one flight segment on a ticket | ticket_no, flight_id, fare_conditions, amount |
Query skeleton: the clauses and their order
| Clause | What it does | Runs |
|---|---|---|
SELECT |
columns and expressions to return | 5th |
FROM / JOIN |
where the rows come from | 1st |
WHERE |
keeps rows that match a condition | 2nd |
GROUP BY |
one row per group | 3rd |
HAVING |
keeps groups that match a condition | 4th |
ORDER BY |
sorts the result | 6th |
LIMIT |
keeps the first N rows | 7th |
You write them in the order of the first column of that table, but the database runs them in the order of the last. That is why an alias defined in SELECT works in ORDER BY but not in WHERE.
SELECT model, range
FROM aircrafts
WHERE range >= 5000
ORDER BY range DESC
LIMIT 3;
| model | range |
|---|---|
| Boeing 777-300 | 11100 |
| Boeing 767-300 | 7900 |
| Airbus A319-100 | 6700 |
Filters (WHERE)
| Operator | Example | Note |
|---|---|---|
=, <>, <, >= |
range >= 5000 |
text goes in single quotes |
AND, OR, NOT |
a AND (b OR c) |
AND binds tighter than OR: use brackets |
IN |
status IN ('Departed', 'Arrived') |
shorthand for several ORs |
BETWEEN |
x BETWEEN 1 AND 10 |
inclusive at both ends |
LIKE / ILIKE |
city LIKE 'Novo%' |
% any characters, _ one character; ILIKE ignores case |
IS NULL |
actual_departure IS NULL |
never = NULL |
SELECT COUNT(*) FILTER (WHERE status IN ('Departed', 'Arrived')) AS flown,
COUNT(*) FILTER (WHERE scheduled_departure BETWEEN '2026-08-01' AND '2026-08-07 23:59:59') AS first_week_aug,
COUNT(*) FILTER (WHERE actual_departure IS NULL) AS not_departed
FROM flights;
| flown | first_week_aug | not_departed |
|---|---|---|
| 16765 | 3798 | 16348 |
FILTER (WHERE ...) lets you put three different filters in one query; each COUNT only sees its own rows. For date ranges, >= '2026-08-01' AND < '2026-08-08' is safer than BETWEEN, because you do not have to remember the 23:59:59.
Aggregates
| Function | Returns |
|---|---|
COUNT(*) |
number of rows |
COUNT(col) |
number of non-NULL values |
COUNT(DISTINCT col) |
number of distinct non-NULL values |
SUM(col), AVG(col) |
total, mean (NULLs ignored) |
MIN(col), MAX(col) |
smallest, largest (works on dates and text too) |
SELECT COUNT(*) AS bookings,
COUNT(DISTINCT DATE_TRUNC('day', book_date)) AS days,
SUM(total_amount) AS revenue,
ROUND(AVG(total_amount), 2) AS avg_booking,
MIN(total_amount) AS min_b,
MAX(total_amount) AS max_b
FROM bookings;
| bookings | days | revenue | avg_booking | min_b | max_b |
|---|---|---|---|---|---|
| 262788 | 56 | 207669809.00 | 790.26 | 34.00 | 12045.00 |
GROUP BY and HAVING
Every column in SELECT must be in GROUP BY or inside an aggregate. WHERE filters rows before grouping; HAVING filters groups after.
SELECT city, COUNT(*) AS airports
FROM airports
GROUP BY city
HAVING COUNT(*) > 1
ORDER BY airports DESC, city;
| city | airports |
|---|---|
| Moscow | 3 |
| Ulyanovsk | 2 |
More on the difference in GROUP BY vs HAVING.
Joins
| Join | Keeps |
|---|---|
JOIN (INNER JOIN) |
only rows with a match on both sides |
LEFT JOIN |
every row of the left table; NULLs where the right side has no match |
RIGHT JOIN |
the mirror of LEFT JOIN (rarely used: swap the tables instead) |
FULL JOIN |
every row from both sides |
CROSS JOIN |
every combination of rows |
LEFT JOIN ... WHERE right.key IS NULL |
rows with no match (an anti join) |
Look up a value from another table:
SELECT f.flight_no, a.model
FROM flights f
JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
WHERE f.flight_id = 1;
| flight_no | model |
|---|---|
| PG0405 | Airbus A321-200 |
Find rows with no partner, here the aircraft model that never flies:
SELECT a.model
FROM aircrafts a
LEFT JOIN flights f ON f.aircraft_code = a.aircraft_code
WHERE f.flight_id IS NULL;
| model |
|---|
| Airbus A320-200 |
The trap to remember: when one row matches several rows, it is repeated once per match. Summing bookings.total_amount after joining tickets gives 343,206,192 instead of the correct 207,669,809, because bookings with several tickets are counted several times. The SQL joins guide covers this fan-out in detail.
CASE: if-then logic
CASE WHEN condition1 THEN value1
WHEN condition2 THEN value2
ELSE default
END
Conditions are checked top to bottom and the first true one wins, so put the narrowest first.
SELECT CASE WHEN total_amount >= 1000 THEN 'large'
WHEN total_amount >= 300 THEN 'medium'
ELSE 'small'
END AS size,
COUNT(*) AS bookings
FROM bookings
GROUP BY size
ORDER BY bookings DESC;
| size | bookings |
|---|---|
| medium | 130009 |
| small | 68305 |
| large | 64474 |
SUM(CASE WHEN ... THEN 1 ELSE 0 END) is the portable way to count by condition; COUNT(*) FILTER (WHERE ...) does the same in PostgreSQL.
NULL handling
| Expression | What it does |
|---|---|
col IS NULL / IS NOT NULL |
the only correct test for NULL |
COALESCE(a, b, ...) |
first non-NULL value |
NULLIF(a, b) |
NULL if a = b, otherwise a (handy to avoid division by zero) |
COUNT(col) |
skips NULLs; COUNT(*) does not |
SELECT flight_no, scheduled_departure, status,
COALESCE(CAST(actual_departure AS VARCHAR), 'not departed') AS actual
FROM flights
WHERE flight_no = 'PG0405'
AND scheduled_departure BETWEEN '2026-08-10' AND '2026-08-13'
ORDER BY scheduled_departure;
| flight_no | scheduled_departure | status | actual |
|---|---|---|---|
| PG0405 | 2026-08-10 09:35:00 | Arrived | 2026-08-10 09:37:00 |
| PG0405 | 2026-08-11 09:35:00 | Arrived | 2026-08-11 09:38:00 |
| PG0405 | 2026-08-12 09:35:00 | On Time | not departed |
A safe percentage, which returns NULL instead of an error if the table is empty:
SELECT ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'Cancelled') / NULLIF(COUNT(*), 0), 2) AS cancelled_pct
FROM flights;
| cancelled_pct |
|---|
| 1.25 |
Write 100.0, not 100: with two integers, some databases do integer division and return 0.
Subqueries and CTEs
A subquery is a query inside another query. Bookings above the average:
SELECT COUNT(*) AS above_avg
FROM bookings
WHERE total_amount > (SELECT AVG(total_amount) FROM bookings);
| above_avg |
|---|
| 87224 |
A CTE (WITH) names a subquery so you can read the query top to bottom. Flights per day, then the minimum, average and maximum of those daily counts:
WITH daily AS (
SELECT CAST(scheduled_departure AS DATE) AS day, COUNT(*) AS flights
FROM flights
GROUP BY day
)
SELECT MIN(flights) AS min_day, ROUND(AVG(flights), 1) AS avg_day, MAX(flights) AS max_day
FROM daily;
| min_day | avg_day | max_day |
|---|---|---|
| 493 | 543 | 568 |
Use a CTE whenever you need to aggregate twice (an average of daily counts, a share of a total) or when the query gets longer than a screen.
Window functions
A window function adds a value to each row without collapsing rows. The shape is function() OVER (PARTITION BY ... ORDER BY ... frame).
| Function | Use it for |
|---|---|
ROW_NUMBER() |
top N per group, removing duplicates |
RANK(), DENSE_RANK() |
rankings with ties (RANK leaves gaps, DENSE_RANK does not) |
LAG(col), LEAD(col) |
previous / next row’s value |
SUM(col) OVER (ORDER BY ...) |
running total |
AVG(col) OVER (ORDER BY ... ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) |
7-row moving average |
SUM(col) OVER () |
grand total next to every row (for shares) |
Day-over-day comparison and a running total in one query:
SELECT CAST(book_date AS DATE) AS day,
COUNT(*) AS bookings,
LAG(COUNT(*)) OVER (ORDER BY CAST(book_date AS DATE)) AS prev_day,
SUM(COUNT(*)) OVER (ORDER BY CAST(book_date AS DATE)) AS running_total
FROM bookings
GROUP BY day
ORDER BY day
LIMIT 4;
| day | bookings | prev_day | running_total |
|---|---|---|---|
| 2026-06-17 | 3 | NULL | 3 |
| 2026-06-18 | 7 | 3 | 10 |
| 2026-06-19 | 20 | 7 | 30 |
| 2026-06-20 | 74 | 20 | 104 |
Top 1 per group with ROW_NUMBER: the busiest departure airport for each aircraft model.
SELECT *
FROM (
SELECT a.model, f.departure_airport, COUNT(*) AS flights,
ROW_NUMBER() OVER (PARTITION BY a.model ORDER BY COUNT(*) DESC) AS rn
FROM flights f
JOIN aircrafts a USING (aircraft_code)
GROUP BY a.model, f.departure_airport
) t
WHERE rn = 1
ORDER BY flights DESC
LIMIT 4;
| model | departure_airport | flights | rn |
|---|---|---|---|
| Sukhoi Superjet-100 | SVO | 1011 | 1 |
| Bombardier CRJ-200 | DME | 829 | 1 |
| Airbus A321-200 | LED | 793 | 1 |
| Cessna 208 Caravan | KJA | 488 | 1 |
You cannot filter on a window function in WHERE of the same query, hence the subquery. Full walkthrough: SQL window functions and running totals.
Dates and times
| Expression | Result |
|---|---|
CAST(ts AS DATE) |
drops the time |
DATE_TRUNC('month', ts) |
first moment of the month (also 'week', 'day', 'hour') |
EXTRACT(MONTH FROM ts) |
month number; also YEAR, DAY, HOUR, ISODOW (1 = Monday) |
ts + INTERVAL '30 days' |
date arithmetic |
date1 - date2 |
days between two dates |
EXTRACT(EPOCH FROM ts1 - ts2) / 60 |
minutes between two timestamps |
Flights per month:
SELECT DATE_TRUNC('month', scheduled_departure) AS month, COUNT(*) AS flights
FROM flights
GROUP BY month
ORDER BY month;
| month | flights |
|---|---|
| 2026-07-01 00:00:00 | 10826 |
| 2026-08-01 00:00:00 | 16854 |
| 2026-09-01 00:00:00 | 5441 |
July and September are partial months here (the schedule runs from 12 July to 10 September), which is worth saying out loud whenever you compare months.
Adding and subtracting dates:
SELECT book_ref, book_date,
book_date + INTERVAL '30 days' AS plus_30,
'2026-08-11'::DATE - CAST(book_date AS DATE) AS days_ago
FROM bookings
ORDER BY book_ref
LIMIT 2;
| book_ref | book_date | plus_30 | days_ago |
|---|---|---|---|
| 00000F | 2026-07-01 03:12:00 | 2026-07-31 03:12:00 | 41 |
| 000012 | 2026-07-10 09:02:00 | 2026-08-09 09:02:00 | 32 |
The longest departure delays, in minutes:
SELECT flight_no, scheduled_departure, actual_departure,
ROUND(EXTRACT(EPOCH FROM actual_departure - scheduled_departure) / 60) AS delay_min
FROM flights
WHERE status = 'Arrived'
ORDER BY delay_min DESC
LIMIT 3;
| flight_no | scheduled_departure | actual_departure | delay_min |
|---|---|---|---|
| PG0589 | 2026-07-25 15:30:00 | 2026-07-25 20:07:00 | 277 |
| PG0164 | 2026-07-25 15:25:00 | 2026-07-25 19:53:00 | 268 |
| PG0364 | 2026-07-15 11:45:00 | 2026-07-15 16:12:00 | 267 |
For days with no rows at all (a day with zero bookings simply does not appear in GROUP BY), see the date spine guide.
Strings
| Function | Example | Result |
|---|---|---|
|| |
'PG' || '0405' |
PG0405 |
UPPER, LOWER |
LOWER('SVO') |
svo |
LENGTH |
LENGTH('Vnukovo') |
7 |
TRIM |
TRIM(' SVO ') |
SVO |
LEFT(s, n), SUBSTRING(s, start, len) |
LEFT('Moscow', 3) |
Mos |
POSITION(sub IN s) |
POSITION('/' IN 'Europe/Moscow') |
7 |
REPLACE(s, from, to) |
REPLACE('Asia/Ust-Nera', '-', ' ') |
Asia/Ust Nera |
SPLIT_PART(s, sep, n) |
SPLIT_PART('IVAN PETROV', ' ', 2) |
PETROV |
json ->> 'key' |
contact_data ->> 'phone' |
the value as text |
Passenger names are stored in capitals as “FIRST LAST”, and contacts are JSON:
SELECT passenger_name,
LOWER(SPLIT_PART(passenger_name, ' ', 2)) AS last_lower,
contact_data ->> 'phone' AS phone
FROM tickets
ORDER BY ticket_no
LIMIT 3;
| passenger_name | last_lower | phone |
|---|---|---|
| VALERIY TIKHONOV | tikhonov | +70127117011 |
| EVGENIYA ALEKSEEVA | alekseeva | +70378089255 |
| ARTUR GERASIMOV | gerasimov | +70760429203 |
Building a label and cutting a string at a character:
SELECT airport_name || ' (' || airport_code || ')' AS label,
SUBSTRING(timezone, 1, POSITION('/' IN timezone) - 1) AS region
FROM airports
WHERE city = 'Moscow'
ORDER BY airport_code;
| label | region |
|---|---|
| Domodedovo (DME) | Europe |
| Sheremetyevo (SVO) | Europe |
| Vnukovo (VKO) | Europe |
Mistakes this cheat sheet saves you from
= NULL. AlwaysIS NULL; the equality returns no rows and no error.LIMITwithoutORDER BY. You get some rows, not the top rows.- Summing after a one-to-many join. Aggregate the “many” side first, then join.
- Integer division.
100 * a / bcan round to 0; write100.0. - Filtering a window function in
WHERE. Wrap it in a subquery or CTE. BETWEENon timestamps.'2026-08-07'means midnight; use>=start and<next day.
Dialect notes
Everything here is PostgreSQL. Most of it is the same in other databases; the usual differences are:
| Task | PostgreSQL | MySQL | SQL Server |
|---|---|---|---|
| first N rows | LIMIT 10 |
LIMIT 10 |
TOP 10 / FETCH FIRST 10 ROWS ONLY |
| concatenate | a || b |
CONCAT(a, b) |
a + b or CONCAT(a, b) |
| truncate a date | DATE_TRUNC('month', d) |
DATE_FORMAT(d, '%Y-%m-01') |
DATETRUNC(month, d) (2022+) |
| case-insensitive LIKE | ILIKE |
LIKE (depends on collation) |
LIKE (depends on collation) |
| conditional count | COUNT(*) FILTER (WHERE ...) |
SUM(CASE WHEN ... THEN 1 ELSE 0 END) |
SUM(CASE WHEN ... THEN 1 ELSE 0 END) |
How to use this cheat sheet
Keep it open while you work, but do not try to memorise it. You remember syntax by using it on questions you care about. In the Nomto SQL room every query above runs on the same airline database, so change a filter or a grouping column and see what moves. If you are just starting, basic SQL queries explains the first half of this page step by step, and the SQL practice problems give you tasks to apply the second half.
FAQ
What SQL should a data analyst know?
Solidly: SELECT, WHERE, GROUP BY, HAVING, joins, CASE, NULL handling, subqueries and CTEs, and date functions. Then window functions (ROW_NUMBER, LAG, running totals), which come up in most analyst interviews. Indexes, stored procedures and database administration matter much less for the role.
Is this cheat sheet valid for MySQL and SQL Server?
The clauses, joins, aggregates, CASE, COALESCE, subqueries, CTEs and window functions work the same way. Date functions, string concatenation and LIMIT differ; the dialect table above lists the common cases.
What is the difference between a subquery and a CTE?
They do the same job. A CTE gives the subquery a name and moves it to the top, so a long query reads in steps. Use whichever is easier to read; for anything with two or more steps, that is usually the CTE.
Can I run these examples without installing a database?
Yes. Open the Nomto SQL room, paste a query and run it in the browser on the same airline data.
Also in Español, Português (Brasil), Tiếng Việt, 简体中文