← Back to Blake's Home

Lesson 24: SQL Review — Schemas & Queries

Reading the database, then asking it questions

May 2026 ~45 min Databases & SQL SE-11-04 · SE-11-08

Two skills, one lesson. First we learn to read a database — the schema and ER diagram that say what tables exist and how they connect. Then we learn to question it with SQL: SELECT, WHERE, ORDER BY, and grouping. We'll use a made-up Volcanic Pantheon leaderboard database the whole way through, so every query is asking something real.

You'll run the queries in a separate SQL environment — here, the focus is reading queries, predicting their output, and writing them correctly.

1. Tables, Rows & Columns ~3 min

A relational database stores data in tables — exactly like a spreadsheet. Some vocabulary you need before anything else:

  • A table represents one type of thing (an entity) — e.g. players, scores.
  • A row (or record) is one instance — one specific player.
  • A column (or field / attribute) is one piece of info every row has — e.g. username.

Here's a players table with three rows:

player_idusernamecountrylevel
1ashfallAU12
2emberlordNZ8
3quietmagmaAU20

3 rows · 4 columns. player_id is what makes each row uniquely identifiable.

2. Reading the Schema & ER Diagram ~10 min

SE-11-04 — Data Storage

Keys: how rows are identified and linked

  • A primary key (PK) is the column that uniquely identifies each row. No two rows can share it, and it's never empty. player_id is the PK of players.
  • A foreign key (FK) is a column that points to the primary key of another table. This is how tables connect. The scores table has a player_id FK saying "this score belongs to that player".

The schema diagram

A schema lists every table, its columns, the data type of each column, and which columns are keys. Here's our leaderboard database. PK marks primary keys, FK marks foreign keys.

players
PKplayer_idINTEGER
usernameTEXT
countryTEXT
levelINTEGER
1 ──< ∞
one player has
many scores
scores
PKscore_idINTEGER
FKplayer_idINTEGER
pointsINTEGER
deathsINTEGER
played_onDATE

Reading the relationship: the line between the tables is the heart of an Entity Relationship (ER) diagram. The player_id FK in scores matches the player_id PK in players. One player can submit many scores, but each score belongs to exactly one player. That's a one-to-many relationship.

Crow's foot notation

ER diagrams mark the "how many" end of each relationship with little symbols. The splayed three-pronged end (the "crow's foot") means many; a single bar means one:

Symbol at the end of the lineMeans
│ (single bar)exactly one
< (crow's foot)many
○ (circle)optional (zero is allowed)

So players │──< scores reads as "one player relates to many scores". Same idea you've seen for a Vercel project: one user account, many saved sessions.

🎯 Reading-the-schema check

If a player is deleted, what could happen to their rows in the scores table?

Their scores reference a player_id that no longer exists — they'd become "orphaned". A well-designed database stops this with rules (e.g. block the delete, or cascade-delete the scores too). This is exactly what foreign keys protect against.

Why can't username be the primary key here?

It could work if usernames are guaranteed unique — but they're text, can change, and are bigger to store/compare. A small, stable, auto-numbered player_id is safer and faster, which is why most tables use a dedicated ID as the PK.

3. SELECT & WHERE — Asking Questions ~9 min

SE-11-08 — SQL

A query has a fixed skeleton. Two clauses to start:

  • SELECT — which columns you want back (or * for all).
  • FROM — which table to read from.
-- Every column, every row, from the players table
SELECT * FROM players;

-- Just two columns
SELECT username, level FROM players;

WHERE — filtering rows

WHERE keeps only the rows that match a condition. The query checks every row and throws away the ones that fail.

-- Players who are level 10 or higher
SELECT username, level
FROM players
WHERE level >= 10;

Conditions can use these operators:

OperatorMeaningExample
=equalscountry = 'AU'
!= or <>not equallevel != 1
> < >= <=comparisonspoints > 5000
AND / ORcombine conditionslevel > 5 AND country = 'AU'
BETWEENin a range (inclusive)level BETWEEN 5 AND 10
INmatches any in a listcountry IN ('AU','NZ')
LIKEtext pattern (% = wildcard)username LIKE 'ash%'

🎯 Predict the output

Using the players table from Section 1 (ashfall/AU/12, emberlord/NZ/8, quietmagma/AU/20):

SELECT username
FROM players
WHERE country = 'AU' AND level > 15;
Which usernames come back?

Only quietmagma. ashfall is AU but level 12 (not > 15); emberlord is level 8 and NZ. Both conditions must be true because of AND.

What would change if AND became OR?

With OR, a row passes if either condition holds: country is AU or level > 15. That returns ashfall and quietmagma (both AU). emberlord still fails both.

4. ORDER BY — Sorting Results ~6 min

SE-11-08 — SQL

The database returns rows in no guaranteed order. ORDER BY sorts them. Add ASC for ascending (smallest first — the default) or DESC for descending (largest first).

-- Highest level first
SELECT username, level
FROM players
ORDER BY level DESC;

For a leaderboard you almost always combine filtering, sorting, and a row limit. LIMIT keeps only the first N rows of the result:

-- Top 3 Australian scores, biggest points first
SELECT points, deaths, played_on
FROM scores
WHERE points > 0
ORDER BY points DESC
LIMIT 3;

Order of writing matters. SQL clauses must appear in a set sequence — SELECTFROMWHEREORDER BYLIMIT. Put WHERE after ORDER BY and it's a syntax error. Keep this skeleton in your head:

SELECTcolumns FROMtable WHEREfilter rows GROUP BYmake groups HAVINGfilter groups ORDER BYsort LIMITcut off

🎯 Your turn — write it

Write a query for the 5 lowest-level players, lowest first.
SELECT username, level
FROM players
ORDER BY level ASC
LIMIT 5;

(ASC is optional since it's the default, but writing it makes your intent clear.)

5. GROUP BY & Aggregates — Summarising ~12 min

SE-11-08 — SQL

Aggregate functions: many rows → one number

Sometimes you don't want individual rows — you want a summary. Aggregate functions take a whole column of values and collapse it to a single answer:

FunctionWhat it returns
COUNT(*)how many rows
SUM(column)total of all values
AVG(column)average (mean)
MAX(column)biggest value
MIN(column)smallest value
-- How many players are there, and the highest level reached?
SELECT COUNT(*), MAX(level)
FROM players;

On our 3-row table that returns a single row: 3 and 20. The whole table collapsed into one summary line.

GROUP BY: one summary per group

Here's the key idea. A bare aggregate summarises the whole table. GROUP BY first splits the rows into buckets that share a value, then runs the aggregate once per bucket.

"How many players are there?" → one number. "How many players are there in each country?" → one number per country. That "in each ___" phrasing is your signal to reach for GROUP BY.

-- Number of players in each country
SELECT country, COUNT(*)
FROM players
GROUP BY country;

SQL groups the rows by country first — AU = {ashfall, quietmagma}, NZ = {emberlord} — then counts each group:

countryCOUNT(*)
AU2
NZ1

The rule: every column in your SELECT must either be one you grouped by (country) or be inside an aggregate function (COUNT(*)). You can't select a raw username here — there are two usernames in the AU group, so SQL wouldn't know which to show.

HAVING: filtering the groups

This trips people up, so go slow. WHERE filters individual rows before grouping. HAVING filters whole groups after the aggregate is calculated. You need HAVING whenever your condition uses an aggregate like COUNT or AVG.

-- Only countries that have more than 1 player
SELECT country, COUNT(*)
FROM players
GROUP BY country
HAVING COUNT(*) > 1;

This returns only AU (2). NZ's group has just 1 player, so HAVING drops the whole group. You couldn't use WHERE COUNT(*) > 1 — the count doesn't exist yet when WHERE runs.

WHERE vs HAVING in one line: WHERE filters rows before grouping; HAVING filters groups after. If the condition mentions an aggregate, it belongs in HAVING.

🎯 Your turn — grouping

Use the scores table (columns: score_id, player_id, points, deaths, played_on).

Write a query: total points each player has scored across all their games.
SELECT player_id, SUM(points)
FROM scores
GROUP BY player_id;

One row per player, each showing their combined points. "Each player" → group by player_id.

Now: only players whose average deaths is under 3.
SELECT player_id, AVG(deaths)
FROM scores
GROUP BY player_id
HAVING AVG(deaths) < 3;

The condition uses an aggregate (AVG), so it must go in HAVING, not WHERE.

Stretch: per player, their best single score — highest first.
SELECT player_id, MAX(points)
FROM scores
GROUP BY player_id
ORDER BY MAX(points) DESC;

This is a real leaderboard query: group by player, take each one's MAX, then sort the groups.

6. Putting It Together ~3 min

One last query that uses almost everything from today. Read it slowly and say out loud what each line does:

SELECT player_id, SUM(points)
FROM scores
WHERE deaths < 5
GROUP BY player_id
HAVING SUM(points) > 1000
ORDER BY SUM(points) DESC
LIMIT 10;

🎯 Translate the query into English

What is this query asking for?

"Looking only at games where the player died fewer than 5 times (WHERE), add up each player's points (GROUP BY + SUM), keep only players whose clean-game total tops 1000 (HAVING), and list the top 10 by total points, highest first (ORDER BY + LIMIT)."

Notice the order the database actually applies them: filter rows → group → aggregate → filter groups → sort → cut off.

7. Wrap-Up & Homework ~2 min

What we covered

  • Schema & ER diagrams — tables, rows, columns; primary keys identify rows, foreign keys link tables; crow's foot shows one-vs-many relationships.
  • SELECT / FROM / WHERE — choose columns, choose a table, filter rows with conditions.
  • ORDER BY / LIMIT — sort the result and cut it down.
  • GROUP BY + aggregates — summarise per group with COUNT/SUM/AVG/MAX/MIN.
  • HAVING — filter groups after aggregating (vs WHERE, which filters rows before).

The clause skeleton — memorise this order

SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT. Get them out of order and SQL throws an error.

Homework (pick one)

  1. Design a schema: sketch the tables you'd need to store Volcanic Pantheon's data — players, sessions, maybe achievements. Mark each PK and FK, and draw the relationships with crow's foot notation.
  2. Write five queries against the scores table from this lesson (one each using WHERE, ORDER BY, COUNT, GROUP BY, and HAVING). Write the English question above each one.
  3. Run them: in the SQL environment Luis sets up, create the players and scores tables, add a few rows, and check that your homework queries return what you predicted.