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

SQL cheat sheet for data analysts: every clause and function with a runnable example

A desk with a printed one-page SQL reference, a laptop showing a query and a coffee cup

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

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, 简体中文