Stan Karzhev

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
  1. 01 Tables, keys and relationships
  2. 02 How a query actually runs
  3. 03 Joins
  4. 04 NULL
  5. 05 Filtering: quotes, IN, LIKE
  6. 06 Conditional columns with CASE
  7. 07 Aggregation and GROUP BY
  8. 08 Window functions and ranking
  9. 09 Subqueries and CTEs
  10. 10 Quick reference

01 Tables, keys and relationships

Every example runs against a trimmed copy of the pgexercises country club.

cd.membersmemid 0 is the guest account
memidPKfirstnamesurnamerecommendedbyFK
0GUESTGUESTNULL
2TracySmithNULL
3TimRownamNULL
4JaniceJoplette1
5GeraldButters1
7NancyDare4
37DarrenSmithNULL
cd.bookingsslots are half-hours
bookidPKmemidFKfacidFKstarttimeslots
0102012-09-21 00:002
1002012-09-21 09:003
2212012-09-21 10:302
32012-09-21 13:003
4432012-09-21 15:002
5042012-09-21 17:302
6312012-09-22 00:002
7502012-09-20 18:002
8742012-09-14 11:002
9202012-09-14 08:003
10422012-09-14 12:001
11332012-08-30 19:002
cd.facilitiescosts are per half-hour slot
facidPKnamemembercostguestcost
0Tennis Court 1525
1Tennis Court 2525
3Table Tennis05
4Massage Room 13580

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 1 fails with duplicate 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 only memid tells them apart.
  • A foreign key (FK) holds a primary key value from another table. bookings has two, so it links members to facilities: many members book many facilities, stored as a row per booking.
  • recommendedby is 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 read members twice, once as the member and once as the recommender, and give the two copies different aliases (mems, rec) so SQL knows which firstname you 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.

12rows
bks.bookidmems.memidfirstnamesurnamebks.slots
01DarrenSmith2
10GUESTGUEST3
22TracySmith2
31DarrenSmith3
44JaniceJoplette2
50GUESTGUEST2
63TimRownam2
75GeraldButters2
87NancyDare2
92TracySmith3
104JaniceJoplette1
113TimRownam2
  • Anything that runs before SELECT can’t see its aliases: WHERE, GROUP BY, HAVING. ORDER BY can.
  • Aggregates filter in HAVING, not WHERE. 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;
cd.members mems
cd.bookings bks
  1. 1Darren Smith×2
  2. 2Tracy Smith×2
  3. 3Tim Rownam×1
  4. 4Janice Joplette×1
  5. 37Darren Smith+NULL
  1. bk 0memid=1
  2. bk 2memid=2
  3. bk 3memid=1
  4. bk 4memid=4
  5. bk 6memid=3
  6. bk 7memid=5gone
  7. 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
join on
result5members7bookings7rows
mems.memidnamebks.bookidbks.memidbks.facid
1Darren Smith010
1Darren Smith312
2Tracy Smith221
2Tracy Smith920
3Tim Rownam631
4Janice Joplette443
37Darren SmithNULLNULLNULL

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.

Which join produced this?question 1 of 5 · 0 right
mems
memidfirstname
3Tim
4Janice
37Darren
bks
bookidmemid
01
44
104

… join bks on bks.memid = mems.memid

result
mems.memidfirstnamebks.bookidbks.memid
3TimNULLNULL
4Janice44
4Janice104
37DarrenNULLNULL

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;
memberrecommendedby= 1<> 1WHERE sees
GUEST GUESTNULLUNKNOWNUNKNOWNUNKNOWNdropped
Darren SmithNULLUNKNOWNUNKNOWNUNKNOWNdropped
Tracy SmithNULLUNKNOWNUNKNOWNUNKNOWNdropped
Tim RownamNULLUNKNOWNUNKNOWNUNKNOWNdropped
Janice Joplette1TRUEFALSETRUEkept
Gerald Butters1TRUEFALSETRUEkept
Nancy Dare4FALSETRUETRUEkept
Darren SmithNULLUNKNOWNUNKNOWNUNKNOWNdropped
3of 8 keptReads like it must be every row: equal to 1, or not equal to 1. For a NULL both sides are UNKNOWN, and UNKNOWN OR UNKNOWN is UNKNOWN.

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);
membermemid <> 1memid <> 4memid <> NULLall ANDed
0 GUEST GUESTTRUETRUEUNKNOWNUNKNOWNdropped
1 Darren SmithFALSETRUEUNKNOWNFALSEdropped
2 Tracy SmithTRUETRUEUNKNOWNUNKNOWNdropped
3 Tim RownamTRUETRUEUNKNOWNUNKNOWNdropped
4 Janice JopletteTRUEFALSEUNKNOWNFALSEdropped
5 Gerald ButtersTRUETRUEUNKNOWNUNKNOWNdropped
7 Nancy DareTRUETRUEUNKNOWNUNKNOWNdropped
37 Darren SmithTRUETRUEUNKNOWNUNKNOWNdropped
0of 8 keptNOT IN (1, 4, NULL) means <> 1 AND <> 4 AND <> NULL. The last column is UNKNOWN for everyone, so no row can come out TRUE: the answer should be six people and is nobody.

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;
memidfirstnamesurnametotal
1DarrenSmith5
2TracySmith5
3TimRownam4
4JaniceJoplette3
5GeraldButters2
7NancyDare2
37DarrenSmithNULL

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
3of 5 facilities

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.
  • AND binds tighter than OR: a or b and c is a 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;
cd.facilities · membercost
case, for Tennis Court 1
  1. when 5 = 0false
  2. when 5 < 10TRUE → 'cheap'
  3. when 5 < 30not checked
  4. no elsenot reached
price_band
  • 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;
11joined rows
Darren Smithmemid 1
m1bk 02 slots
m1bk 33 slots
count(*) 2count(bookid) 2sum 5
Tracy Smithmemid 2
m2bk 22 slots
m2bk 93 slots
count(*) 2count(bookid) 2sum 5
Tim Rownammemid 3
m3bk 62 slots
m3bk 112 slots
count(*) 2count(bookid) 2sum 4
Janice Joplettememid 4
m4bk 42 slots
m4bk 101 slots
count(*) 2count(bookid) 2sum 3
Gerald Buttersmemid 5
m5bk 72 slots
count(*) 1count(bookid) 1sum 2
Nancy Darememid 7
m7bk 82 slots
count(*) 1count(bookid) 1sum 2
Darren Smithmemid 37
m37bk NULLNULL slots
count(*) 1count(bookid) 0sum NULL

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.memid and you can still select firstname and surname without grouping by them, because one memid means one member, so each group has only one name.
  • Group by the key, not the display name.
  • slots are half-hours: hours are sum(slots) / 2.0, and round(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;
memidnamestarttimeslotsrunning
1Darren2012-09-21 00:0022
1Darren2012-09-21 13:0035
2Tracy2012-09-14 08:0033
2Tracy2012-09-21 10:3025
3Tim2012-08-30 19:0022
3Tim2012-09-22 00:0024
4Janice2012-09-14 12:0011
4Janice2012-09-21 15:0023
5Gerald2012-09-20 18:0022
7Nancy2012-09-14 11:0022

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;
Darren5111
Tracy5112
Tim4323
Janice3434
Gerald2545
Nancy2546

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

WhereMust returnUse
SELECT listone valuelook something up per row
FROM, with an aliasa tablemake an alias filterable
WHERE with IN / EXISTSa list, or true/falseis 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;
outer row 5 of 8
inner query, for mems.memid 4

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.

output so far
memberrecommender
GUEST GUESTNULL
Darren SmithNULL
Tracy SmithNULL
Tim RownamNULL
Janice JopletteDarren

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;
members.memid · 8
012345737
bookings.memid · 12
102140357243
result 8rows
0×3 merged1×3 merged2×3 merged3×3 merged4×3 merged5×2 merged7×2 merged37

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

SyntaxMeaningPortable alternative
x::dateCastcast(x as date)
ilikeCase-insensitive LIKElower(col) like lower(…)
~, ~*Regex matchVaries by database
date_trunc, date_partTruncate or extract timestamp partsextract(…) is standard
GROUP BY a SELECT aliasConvenienceRepeat the expression
concat() vs ||concat skips NULLs, || propagates themVaries

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.