projects / sql
SQL, the parts I keep looking up
Built while working through pgexercises. Each section is a concept in a line or two, an interactive that shows it, and the mistakes that cost me time. Postgres syntax is used throughout.
Every query marked try it ▸ runs in a real Postgres in your browser. Or .
contents 01 Tables, keys and relationships
01 Tables, keys and relationships
Every example runs against a trimmed copy of the pgexercises country club.
| memidPK | firstname | surname | recommendedbyFK |
|---|---|---|---|
| 0 | GUEST | GUEST | NULL |
| 1 | Darren | Smith | NULL |
| 2 | Tracy | Smith | NULL |
| 3 | Tim | Rownam | NULL |
| 4 | Janice | Joplette | 1 |
| 5 | Gerald | Butters | 1 |
| 7 | Nancy | Dare | 4 |
| 37 | Darren | Smith | NULL |
| bookidPK | memidFK | facidFK | starttime | slots |
|---|---|---|---|---|
| 0 | 1 | 0 | 2012-09-21 00:00 | 2 |
| 1 | 0 | 0 | 2012-09-21 09:00 | 3 |
| 2 | 2 | 1 | 2012-09-21 10:30 | 2 |
| 3 | 1 | 2 | 2012-09-21 13:00 | 3 |
| 4 | 4 | 3 | 2012-09-21 15:00 | 2 |
| 5 | 0 | 4 | 2012-09-21 17:30 | 2 |
| 6 | 3 | 1 | 2012-09-22 00:00 | 2 |
| 7 | 5 | 0 | 2012-09-20 18:00 | 2 |
| 8 | 7 | 4 | 2012-09-14 11:00 | 2 |
| 9 | 2 | 0 | 2012-09-14 08:00 | 3 |
| 10 | 4 | 2 | 2012-09-14 12:00 | 1 |
| 11 | 3 | 3 | 2012-08-30 19:00 | 2 |
| facidPK | name | membercost | guestcost |
|---|---|---|---|
| 0 | Tennis Court 1 | 5 | 25 |
| 1 | Tennis Court 2 | 5 | 25 |
| 2 | Badminton Court | 0 | 15.5 |
| 3 | Table Tennis | 0 | 5 |
| 4 | Massage Room 1 | 35 | 80 |
Booking 3: memid 1 → Darren Smith, facid 2 → Badminton Court. Each booking has exactly one of each.
- A primary key (PK) identifies a row. Postgres enforces it: a second row with
memid 1fails withduplicate key value violates unique constraint "members_pkey", and a NULL key fails too. Names have no such rule, which is why there are two Darren Smiths (1 and 37) and onlymemidtells them apart. - A foreign key (FK) holds a primary key value from another table.
bookingshas two, so it links members to facilities: many members book many facilities, stored as a row per booking. recommendedbyis a foreign key in its own table: it points at another member, this is referred to as a self-reference (not a circular dependency). To show a recommender’s name you readmemberstwice, once as the member and once as the recommender, and give the two copies different aliases (mems,rec) so SQL knows whichfirstnameyou mean. Hover Janice above to see it.
02 How a query actually runs
You write SELECT first; it runs fifth.
select distinct mems.surname, sum(bks.slots) as total56
from cd.members mems1
join cd.bookings bks on bks.memid = mems.memid
where mems.memid <> 02
group by mems.memid3
having sum(bks.slots) > 24
order by total desc, mems.surname7
limit 2;8
Postgres starts with the tables. The join pairs each booking with its member: 12 bookings, so 12 rows. Nothing from the SELECT line exists yet.
| bks.bookid | mems.memid | firstname | surname | bks.slots |
|---|---|---|---|---|
| 0 | 1 | Darren | Smith | 2 |
| 1 | 0 | GUEST | GUEST | 3 |
| 2 | 2 | Tracy | Smith | 2 |
| 3 | 1 | Darren | Smith | 3 |
| 4 | 4 | Janice | Joplette | 2 |
| 5 | 0 | GUEST | GUEST | 2 |
| 6 | 3 | Tim | Rownam | 2 |
| 7 | 5 | Gerald | Butters | 2 |
| 8 | 7 | Nancy | Dare | 2 |
| 9 | 2 | Tracy | Smith | 3 |
| 10 | 4 | Janice | Joplette | 1 |
| 11 | 3 | Tim | Rownam | 2 |
- Anything that runs before
SELECTcan’t see its aliases:WHERE,GROUP BY,HAVING.ORDER BYcan. - Aggregates filter in
HAVING, notWHERE. Window functions can’t be filtered in either: wrap the query and filter outside. - This order decides what a query means, not how fast it runs. Postgres’s planner, the part that decides how to actually fetch the rows, is free to do the work in another order (filtering before joining, say) as long as the result comes out the same.
Further reading: Julia Evans, SQL queries don’t start with SELECT.
03 Joins
ON decides which rows pair up. The join type only decides what happens to rows that find no partner.
select *
from cd.members mems
left join cd.bookings bks
on bks.memid = mems.memid;
- 1Darren Smith×2
- 2Tracy Smith×2
- 3Tim Rownam×1
- 4Janice Joplette×1
- 37Darren Smith+NULL
- bk 0memid=1
- bk 2memid=2
- bk 3memid=1
- bk 4memid=4
- bk 6memid=3
- bk 7memid=5gone
- bk 9memid=2
- matched: one output row per line
- 1 member with no booking: kept, booking columns NULL
- 1 booking with no member here: dropped
| mems.memid | name | bks.bookid | bks.memid | bks.facid |
|---|---|---|---|---|
| 1 | Darren Smith | 0 | 1 | 0 |
| 1 | Darren Smith | 3 | 1 | 2 |
| 2 | Tracy Smith | 2 | 2 | 1 |
| 2 | Tracy Smith | 9 | 2 | 0 |
| 3 | Tim Rownam | 6 | 3 | 1 |
| 4 | Janice Joplette | 4 | 4 | 3 |
| 37 | Darren Smith | NULL | NULL | NULL |
Count rows after every join, the way you would after a pandas merge. Joining from the many side to the one side on its key should never change the row count. If it does, the key isn’t what you think.
| memid | firstname |
|---|---|
| 3 | Tim |
| 4 | Janice |
| 37 | Darren |
| bookid | memid |
|---|---|
| 0 | 1 |
| 4 | 4 |
| 10 | 4 |
… join bks on bks.memid = mems.memid
| mems.memid | firstname | bks.bookid | bks.memid |
|---|---|---|---|
| 3 | Tim | NULL | NULL |
| 4 | Janice | 4 | 4 |
| 4 | Janice | 10 | 4 |
| 37 | Darren | NULL | NULL |
Look for NULLs, and on which side they are.
Patterns
-- anti-join: members with no bookings
select mems.memid
from cd.members mems
left join cd.bookings bks on bks.memid = mems.memid
where bks.memid is null;
-- semi-join: members who have at least one booking, listed once each
-- (a join would list a member once per booking)
select mems.memid
from cd.members mems
where exists (select 1 from cd.bookings bks where bks.memid = mems.memid);
-- self join: each member with their recommender
select mems.firstname, rec.firstname as recommender
from cd.members mems
left join cd.members rec on rec.memid = mems.recommendedby;
Most joins you write are LEFT or INNER. RIGHT JOIN is a LEFT JOIN with the tables swapped, so it’s rarely written; put the table you want to keep first and use LEFT. CROSS JOIN pairs every row with every row and is only for when you want every combination. A comma between tables, FROM a, b, is also a cross join, which is how a forgotten join condition quietly gives you m × n rows.
04 NULL
SQL has a third truth value. Any comparison with NULL, even NULL = NULL, is neither TRUE nor FALSE but UNKNOWN, and WHERE keeps a row only when its condition is TRUE. Five members have no recommender:
select firstname, surname, recommendedby
from cd.members
where recommendedby = 1 or recommendedby <> 1;
| member | recommendedby | = 1 | <> 1 | WHERE sees | |
|---|---|---|---|---|---|
| GUEST GUEST | NULL | UNKNOWN | UNKNOWN | UNKNOWN | dropped |
| Darren Smith | NULL | UNKNOWN | UNKNOWN | UNKNOWN | dropped |
| Tracy Smith | NULL | UNKNOWN | UNKNOWN | UNKNOWN | dropped |
| Tim Rownam | NULL | UNKNOWN | UNKNOWN | UNKNOWN | dropped |
| Janice Joplette | 1 | TRUE | FALSE | TRUE | kept |
| Gerald Butters | 1 | TRUE | FALSE | TRUE | kept |
| Nancy Dare | 4 | FALSE | TRUE | TRUE | kept |
| Darren Smith | NULL | UNKNOWN | UNKNOWN | UNKNOWN | dropped |
The same thing breaks NOT IN. x not in (1, 4, NULL) is shorthand for x <> 1 and x <> 4 and x <> NULL, and that last part is UNKNOWN for every x:
Who has never recommended anyone?
select firstname, surname
from cd.members
where memid not in (select recommendedby from cd.members);
| member | memid <> 1 | memid <> 4 | memid <> NULL | all ANDed | |
|---|---|---|---|---|---|
| 0 GUEST GUEST | TRUE | TRUE | UNKNOWN | UNKNOWN | dropped |
| 1 Darren Smith | FALSE | TRUE | UNKNOWN | FALSE | dropped |
| 2 Tracy Smith | TRUE | TRUE | UNKNOWN | UNKNOWN | dropped |
| 3 Tim Rownam | TRUE | TRUE | UNKNOWN | UNKNOWN | dropped |
| 4 Janice Joplette | TRUE | FALSE | UNKNOWN | FALSE | dropped |
| 5 Gerald Butters | TRUE | TRUE | UNKNOWN | UNKNOWN | dropped |
| 7 Nancy Dare | TRUE | TRUE | UNKNOWN | UNKNOWN | dropped |
| 37 Darren Smith | TRUE | TRUE | UNKNOWN | UNKNOWN | dropped |
Outer joins create NULLs, and aggregates skip them. count(*) counts rows, count(col) counts non-NULL values, and sum over nothing but NULLs is NULL:
select mems.memid, mems.firstname, mems.surname,
sum(bks.slots) as total
from cd.members mems
left join cd.bookings bks on bks.memid = mems.memid
where mems.memid <> 0
group by mems.memid;
| memid | firstname | surname | total |
|---|---|---|---|
| 1 | Darren | Smith | 5 |
| 2 | Tracy | Smith | 5 |
| 3 | Tim | Rownam | 4 |
| 4 | Janice | Joplette | 3 |
| 5 | Gerald | Butters | 2 |
| 7 | Nancy | Dare | 2 |
| 37 | Darren | Smith | NULL |
Darren 37 has no bookings. The LEFT JOIN gives him one row with NULL slots, and sum over nothing but NULLs is NULL, not 0.
05 Filtering: quotes, IN, LIKE
'Tennis' is a string. "Tennis" is a column called Tennis. IN (…) matches exact values from a list; LIKE matches a pattern.
where name like''reads as anything (or nothing), then "Tennis", then anything (or nothing)
- ✓Tennis Court 1
- ✓Tennis Court 2
- ·Badminton Court
- ✓Table Tennis
- ·Massage Room 1
The whole name has to match the pattern, which is why a word in the middle needs % on both sides.
~is a regex match in Postgres,~*case-insensitive.ANDbinds tighter thanOR:a or b and cisa or (b and c). Parenthesise anything that mixes them.
06 Conditional columns with CASE
CASE returns a value, so it goes anywhere a value can: SELECT, WHERE, ORDER BY, inside an aggregate. The first matching WHEN wins; no match and no ELSE gives NULL.
select name, membercost,
case when membercost = 0 then 'free'
when membercost < 10 then 'cheap'
when membercost < 30 then 'mid'
end as price_band
from cd.facilities;
when 5 = 0falsewhen 5 < 10TRUE → 'cheap'when 5 < 30not checkedno elsenot reached
- cheap
Tennis Court 1: 1 branch came out false before one matched. Branches run top to bottom and the first TRUE wins.
-- pick which column to calculate with
select bks.bookid,
case when bks.memid = 0 then bks.slots * facs.guestcost
else bks.slots * facs.membercost end as cost
from cd.bookings bks
join cd.facilities facs on facs.facid = bks.facid;
-- count conditionally
select count(case when memid = 0 then 1 end) as guest_bookings
from cd.bookings; 07 Aggregation and GROUP BY
select mems.firstname, mems.surname,
count(*), count(bks.bookid), sum(bks.slots)
from cd.members mems
left join cd.bookings bks on bks.memid = mems.memid
where mems.memid <> 0
group by mems.memid;
| m1 | bk 0 | 2 slots |
| m1 | bk 3 | 3 slots |
| m2 | bk 2 | 2 slots |
| m2 | bk 9 | 3 slots |
| m3 | bk 6 | 2 slots |
| m3 | bk 11 | 2 slots |
| m4 | bk 4 | 2 slots |
| m4 | bk 10 | 1 slots |
| m5 | bk 7 | 2 slots |
| m7 | bk 8 | 2 slots |
| m37 | bk NULL | NULL slots |
Grouped by the key, the two Darrens stay apart. The LEFT JOIN keeps Darren 37, who never booked: one row, so count(*) is 1, but count(bks.bookid) is 0 and sum over nothing is NULL. Wrap it in coalesce(sum(bks.slots), 0) if you want members who never booked to show 0, or use an inner join if you only want members who booked.
- Every selected column is grouped or aggregated, so Postgres knows which single value to show per group. One exception: group by
mems.memidand you can still selectfirstnameandsurnamewithout grouping by them, because one memid means one member, so each group has only one name. - Group by the key, not the display name.
slotsare half-hours: hours aresum(slots) / 2.0, andround(x, -1)rounds to the nearest 10.
08 Window functions and ranking
f() over (partition by … order by …) adds a column computed across related rows, and keeps every row.
select memid, starttime, slots,
sum(slots) over (partition by memid order by starttime) as running
from cd.bookings
where memid <> 0;
| memid | name | starttime | slots | running |
|---|---|---|---|---|
| 1 | Darren | 2012-09-21 00:00 | 2 | 2 |
| 1 | Darren | 2012-09-21 13:00 | 3 | 5 |
| 2 | Tracy | 2012-09-14 08:00 | 3 | 3 |
| 2 | Tracy | 2012-09-21 10:30 | 2 | 5 |
| 3 | Tim | 2012-08-30 19:00 | 2 | 2 |
| 3 | Tim | 2012-09-22 00:00 | 2 | 4 |
| 4 | Janice | 2012-09-14 12:00 | 1 | 1 |
| 4 | Janice | 2012-09-21 15:00 | 2 | 3 |
| 5 | Gerald | 2012-09-20 18:00 | 2 | 2 |
| 7 | Nancy | 2012-09-14 11:00 | 2 | 2 |
Row for Tracy at 09-21 10:30: summing the 2 highlighted rows gives 5. The frame starts at the top of this member’s partition and stops at the current row.
Every input row is still here, which is the difference from GROUP BY. Hover a row to see its frame.
Ranking is where ties matter:
select mems.firstname, sum(bks.slots) as total,
rank() over (order by sum(bks.slots) desc),
dense_rank() over (order by sum(bks.slots) desc),
row_number() over (order by sum(bks.slots) desc)
from cd.members mems
join cd.bookings bks on bks.memid = mems.memid
where mems.memid <> 0
group by mems.memid;
Tied rows share a rank, and the next rank skips ahead (1, 1, 3). dense_rank never skips (1, 1, 2). row_number numbers every row, so within a tie it picks an order, and nothing in the query says which. Run it again: the underlined numbers can swap.
You can rank an aggregate, rank() over (order by sum(slots) desc): GROUP BY has already run. For a repeatable row_number, add a tie-breaker to the ORDER BY.
09 Subqueries and CTEs
| Where | Must return | Use |
|---|---|---|
SELECT list | one value | look something up per row |
FROM, with an alias | a table | make an alias filterable |
WHERE with IN / EXISTS | a list, or true/false | is it in there? |
select mems.firstname, mems.surname,
(select rec.firstname
from cd.members rec
where rec.memid = mems.recommendedby) as recommender
from cd.members mems;
where rec.memid = 1
- 0 GUEST
- 1 Darren
- 2 Tracy
- 3 Tim
- 4 Janice
- 5 Gerald
- 7 Nancy
- 37 Darren
One row matches, so the subquery returns Darren.
| member | recommender |
|---|---|
| GUEST GUEST | NULL |
| Darren Smith | NULL |
| Tracy Smith | NULL |
| Tim Rownam | NULL |
| Janice Joplette | Darren |
It refers to mems from the outer query, so it runs once per outer row. If it ever found two rows it would fail: a subquery in SELECT must return one value.
A CTE is a derived table moved to the top and given a name:
with costed as (
select bks.bookid,
case when bks.memid = 0 then bks.slots * facs.guestcost
else bks.slots * facs.membercost end as cost
from cd.bookings bks
join cd.facilities facs on facs.facid = bks.facid
)
select * from costed where cost > 30;
10 Quick reference
Templates
-- filter, conditional column, sort
select col, case when cond then 'a' else 'b' end as label
from t
where col like '%text%'
order by label;
-- join
select a.x, b.y
from a
left join b on b.a_id = a.id;
-- aggregate with a group filter
select key, sum(val) as total
from t
group by key
having sum(val) > 100;
-- rank over an aggregate
select key, sum(val) as total,
rank() over (order by sum(val) desc) as rnk
from t
group by key;
-- filter on a computed column
select *
from (select *, expr as calc from t) as sub
where calc > 30;
-- whole-day timestamp filter (= '2012-09-21' would match only midnight)
where ts >= '2012-09-21' and ts < '2012-09-22'
Stacking results: UNION
Joins put tables side by side; set operations stack one result on top of another.
select memid from cd.members
union
select memid from cd.bookings
order by 1;
Stacked, then duplicates removed. Each value appears once however many times it came in.
Columns are matched by position, not by name, and the output takes its column names from the first SELECT. One ORDER BY at the end sorts the combined result.
Postgres-only, or Postgres-flavoured
| Syntax | Meaning | Portable alternative |
|---|---|---|
x::date | Cast | cast(x as date) |
ilike | Case-insensitive LIKE | lower(col) like lower(…) |
~, ~* | Regex match | Varies by database |
date_trunc, date_part | Truncate or extract timestamp parts | extract(…) is standard |
GROUP BY a SELECT alias | Convenience | Repeat the expression |
concat() vs || | concat skips NULLs, || propagates them | Varies |
Credits
Every practice question, and the country club schema, comes from pgexercises by Alisdair Owens. The data here is trimmed and adjusted to seed edge cases.