Sample queries · classicmodels

SQL by example, one join at a time

Ten levels of real, working SQL — starting with a single table and ending with five tables joined together, subqueries, CTEs and window functions. Every query on this page runs as-is against the classicmodels sample database, in DBeaver or the browser SQL Editor.

The best way to learn SQL is to read a lot of it. Each level below adds exactly one new idea and shows the statement that uses it — no toy examples, no foo and bar, just the kind of query you'd actually write against this data.

Snippets rewrite themselves for the engine you pick.

🔤 The two dialects differ in small, annoying ways

Same data, same answers — but on SQL Server the tables live in a schema that's also called classicmodels, so you must write classicmodels.products rather than bare products. And row limits differ: MySQL puts LIMIT 10 at the end, SQL Server puts SELECT TOP 10 at the start. Flip the switch above and every snippet adjusts.

The ten levels

1

One table

1 table · products

Every SQL statement starts here: pick columns, pick a table, put them in order. Read it top-to-bottom and it's almost English.

The 10 most expensive products to buy.


      

Products running low on stock.


      
What's new: SELECT chooses columns · FROM chooses the table · WHERE filters rows · ORDER BY sorts them.
2

Summarise one table

1 table · products, customers

Same single table, but instead of listing rows you collapse them into numbers. This is where SQL starts doing work a spreadsheet would struggle with.

Three numbers describing the whole catalogue.


      

One row per product line.


      

Only countries with 5+ customers.


      

New to aggregates? There's a full explainer with accounting examples →

What's new: COUNT/SUM/AVG aggregate many rows into one · GROUP BY makes one result row per group · HAVING filters the groups (WHERE filters rows, before grouping — that's the classic gotcha).
3

Two tables

2 tables · productlines → products

The first real join. Each product belongs to a product line, so we follow that foreign key to bring both tables together.

How many products in each product line?


      
What's new: JOIN … ON connects two tables by the column they share. The ON clause is the wiring; get it wrong and you get nonsense (or every row times every row).
4

Three tables

3 tables · orderdetails → products, orders

Now a genuine business question: which products actually made money on shipped orders? No single table can answer that — the money is in one table, the product names in another, the order status in a third.

Top 10 products by revenue, shipped orders only.


      
What's new: Joins chain. Each extra JOIN pulls in one more table, and you can filter (WHERE) and aggregate (GROUP BY) across all of them at once.
5

Five tables

5 tables · orderdetails → orders → customers → employees → offices

The full chain, and the payoff. Revenue lives on the order line; the sales rep is four hops away. In a spreadsheet this is a morning of VLOOKUPs. In SQL it's one statement.

Revenue by sales rep, with their office.


      
What's new: Nothing new — just more of the same. That's the point: once you can join two tables you can join ten. Follow the foreign keys one hop at a time.
6

Find what isn't there

2 tables · customers ⟕ orders

An INNER JOIN only keeps rows that match. A LEFT JOIN keeps every row on the left even when there's no match — which lets you ask the opposite question: which customers have never ordered?

Customers who have never placed an order.


      
What's new: LEFT JOIN keeps unmatched left-hand rows and fills the right side with NULL. Adding WHERE … IS NULL turns it into an anti-join — a search for absence.
7

A table joined to itself

1 table, twice · employees ⟕ employees

Managers are employees too, so employees.reportsTo points back at the same table. Join it to itself with two different aliases and you get an org chart.

Every employee and the manager they report to.


      
What's new: Aliases (e, m) stop being a convenience and become essential — they're the only thing telling the two copies apart.
8

A query inside a query

nested · products, customers, orders

Sometimes you need an answer before you can ask the real question — like the average price, or the list of customers who cancelled. Subqueries let you nest one inside the other.

Products priced above the catalogue average.


      

Customers who have cancelled an order.


      
What's new: A scalar subquery returns one value you can compare against. IN (SELECT …) returns a list to match against. The inner query runs first.
9

Name your steps (CTEs)

nested, readable · orderdetails → products

Subqueries get unreadable fast when they nest. A CTE lifts the inner query out, gives it a name, and lets you use it like a table. Same result as a subquery — far kinder to the next person who reads it.

Top 10 products by revenue — same answer, readable.


      
What's new: WITH name AS ( … ) defines a temporary, named result set for this statement only. Chain several and a scary query becomes a readable pipeline.
10

Window functions

advanced · products, orders → orderdetails

GROUP BY collapses rows. A window function does the maths without collapsing anything — so you keep every row AND get the ranking or running total beside it. This is the point where SQL stops being a spreadsheet and starts being a language.

Rank products by price *within* each product line.


      

Daily revenue and a running cumulative total.


      
What's new: OVER (…) defines a 'window' of rows to calculate across. PARTITION BY restarts it per group; ORDER BY inside OVER makes it cumulative.

Where to next