0Game terminology
The names below are the game's vocabulary. They describe the learning experience; SQL itself follows SQLite syntax.
IHow the Keep reads SQL
- Write one statement that starts with
SELECTorWITH. A trailing;is fine. - Keywords are not case-sensitive:
selectandSELECTare the same. Table and column names are not either. - Text values go in single quotes:
'Saffron'. Double quotes are for names:"orders". - The Steward compares your result, not your query. Any correct SQL that returns the right rows wins.
IIThe ledger
These are the tables in catalogue-2-5.sqlite, read from the file itself. Each quest names the tables it needs.
Accounts_Receivable_Payable · 1000 rows
| Column | Type | |
|---|---|---|
Receivable Amount | TEXT | |
Payable Amount | TEXT | |
Due Date | TEXT | |
Payment Status | TEXT |
Dates are stored as text in the form YYYY-MM-DD, which sorts and compares correctly as text.
IIISELECT and columns
Pick the columns you want, in the order you want them. AS renames a column in the result.
SELECT name, region_id AS region FROM customers LIMIT 4
SELECT * returns every column — handy for a first look with RUN, rarely what an answer wants.
DISTINCT removes duplicate rows from the result:
SELECT DISTINCT product FROM order_items ORDER BY product
IVFiltering rows
WHERE keeps only the rows where the condition is true.
| Write | Means |
|---|---|
= <> < >= | equal, not equal, less than, at least |
AND OR NOT | combine conditions — use brackets when you mix AND and OR |
IN ('a', 'b') | matches any value in the list |
BETWEEN 10 AND 20 | 10 to 20, both ends included |
LIKE 'Sa%' | text pattern: % any run of characters, _ exactly one |
IS NULL | the value is missing (never = NULL) |
SELECT id, product, qty FROM order_items WHERE product IN ('Amber', 'Saffron') AND qty >= 2
VSorting and limiting
ORDER BY sorts; add DESC for largest first. Sort by several columns to break ties. LIMIT keeps the first rows after sorting.
SELECT product, unit_price FROM order_items ORDER BY unit_price DESC, product LIMIT 3
ORDER BY the order of rows is not guaranteed. If a quest says “highest first” or “in order”, sort.VICounting and totals
Aggregates fold many rows into one value: COUNT, SUM, AVG, MIN, MAX. GROUP BY makes one result row per group; HAVING filters groups after they are totalled (WHERE filters rows before).
SELECT region_id, COUNT(*) AS customers FROM customers GROUP BY region_id ORDER BY region_id
SELECT product, SUM(qty * unit_price) AS takings FROM order_items GROUP BY product HAVING SUM(qty * unit_price) > 1000 ORDER BY takings DESC
SELECT must either be in the GROUP BY or inside an aggregate. SQLite lets you break this rule and quietly picks any row — a common source of wrong answers.VIIJoining tables
JOIN … ON pairs rows from two tables where the condition holds. Give tables short aliases and prefix columns with them.
SELECT c.name, r.name AS region FROM customers c JOIN regions r ON r.id = c.region_id ORDER BY c.name LIMIT 4
LEFT JOIN keeps every row from the left table even with no match — the right side is then NULL. That makes it the tool for “which ones have none?”:
SELECT r.name, COUNT(c.id) AS customers FROM regions r LEFT JOIN customers c ON c.region_id = r.id GROUP BY r.id, r.name ORDER BY r.name
A query may use at most 5 tables in total (a CTE counts as all the tables inside it). No quest needs more.
VIIISubqueries
A query in brackets can stand in for a value, a list, or a table.
SELECT product, unit_price FROM order_items WHERE unit_price > (SELECT AVG(unit_price) FROM order_items) ORDER BY unit_price DESC
SELECT name FROM regions WHERE id IN (SELECT region_id FROM customers WHERE joined_on >= '2026-01-01') ORDER BY name
IXWITH (CTEs)
WITH name AS (…) names a query so the main query can use it like a table. It makes long answers readable. Several can be chained with commas.
WITH takings AS ( SELECT product, SUM(qty * unit_price) AS total FROM order_items GROUP BY product ) SELECT product, total FROM takings WHERE total > (SELECT AVG(total) FROM takings) ORDER BY total DESC
RECURSIVE) are not available in the Keep.XWindow functions
A window function computes over related rows without folding them into one. OVER (PARTITION BY … ORDER BY …) says which rows, in what order.
| Function | Gives |
|---|---|
ROW_NUMBER() | 1, 2, 3 … within each partition, no ties |
RANK() / DENSE_RANK() | 1, 1, 3 … / 1, 1, 2 … — ties share a rank |
SUM(x) OVER (ORDER BY …) | a running total |
LAG(x) / LEAD(x) | the previous / next row's value |
SELECT product, qty, ROW_NUMBER() OVER (PARTITION BY product ORDER BY qty DESC) AS nth FROM order_items ORDER BY product, nth
SELECT joined_on, name, COUNT(*) OVER (ORDER BY joined_on) AS members_so_far FROM customers ORDER BY joined_on
To keep only the top row of each group, number the rows in a CTE and filter on the number in the main query.
XICASE
CASE WHEN … THEN … ELSE … END chooses a value per row.
SELECT product, qty, CASE WHEN qty >= 10 THEN 'bulk' WHEN qty >= 4 THEN 'crate' ELSE 'handful' END AS size FROM order_items ORDER BY qty DESC LIMIT 5
XIIDates
Dates are YYYY-MM-DD text, so >= and BETWEEN work directly. strftime pulls parts out: '%Y' year, '%m' month, '%Y-%m' year-month.
SELECT strftime('%Y-%m', joined_on) AS month, COUNT(*) AS joined FROM customers GROUP BY month ORDER BY month
date('2026-03-01', '+1 month') moves a date. “On or after 1 March” is >= '2026-03-01'; “before 2026” is < '2026-01-01'.
XIIIText and numbers
| Write | Gives |
|---|---|
a || ' ' || b | joined text |
upper(x) lower(x) length(x) trim(x) | case, length, spaces removed |
substr(x, 1, 3) replace(x, 'a', 'b') | part of the text, swapped text |
ROUND(x, 2) | rounded to 2 decimals |
CAST(x AS REAL) | as a decimal number |
printf('%.2f', x) | formatted text (widths up to 99) |
SELECT 7 / 2 AS whole, 7 / 2.0 AS exact, ROUND(7 / 3.0, 2) AS rounded
| whole | exact | rounded |
|---|---|---|
| 3 | 3.5 | 2.33 |
7 / 2 is 3. Use 2.0 or CAST to get 3.5.XIVNULL
NULL means “no value”. It is not zero and not empty text. Anything compared with NULL is unknown, so WHERE x = NULL never matches — write x IS NULL.
COUNT(*)counts rows;COUNT(col)counts rows wherecolis not NULL.SUMandAVGskip NULLs.COALESCE(x, 0)gives0whenxis NULL.
XVHow answers are graded
When you SUBMIT, the Steward runs the quest's own answer on the same ledger and compares the two results.
| Checked | How |
|---|---|
| Row count | must match exactly |
| Columns | compared by position — the names and aliases you choose do not matter, the order does |
| Row order | matters only when the quest asks for an order (“highest first”, “alphabetical”); otherwise any order is fine |
| Numbers | compared to 4 decimal places, so 12, 12.0 and 12.00001 are equal |
| Text | compared exactly, except that surrounding spaces and repeated spaces are ignored |
| NULL | only matches NULL — not 0, not '', not the word 'NULL' |
| The ledger | your answer must read at least one table; typing the answer in as values is refused (no life spent) |
XVIWhat is not allowed
| Refused | Why |
|---|---|
Anything but SELECT / WITH | the ledger is read-only |
Two statements, ; in the middle | one answer per submit |
Comments -- /* */ | they hide text from the checks |
| Recursive CTEs | a single one can run forever |
| More than 5 tables in one query | cross joins grow as rowsN |
printf widths over 99, randomblob, zeroblob | they build huge values from a short query |
sqlite_master, pragma_… | the schema is on this page instead |
Parameters ? :name | there is nothing to bind them to |
A refused query never costs a life.
XVIIMessages you may see
| Message | What to do |
|---|---|
| The wrong number of rows came back | check your filters, joins and LIMIT; a join can duplicate rows |
| Right rows, wrong order | add or fix ORDER BY |
| Those are not the rows the Steward wants | compare column by column with what the quest asks, in that order |
| The ledger could not parse that | first check commas, quotes, parentheses, table names and column names. The message can also mean SQLite rejected the query during preparation; if a simple valid query repeatedly fails, report it as a possible engine or dataset issue. |
| The ledger could not run that | SQLite could not execute the prepared query. Try a smaller query; if it repeats, report the exact SQL, quest and approximate time to an admin. |
| Your answer must come out of the ledger | query the tables rather than typing values |
| Too many tables joined | simplify; at most 5 |
| The candle has gone out | the quest's time ran out; the next day brings a new one |
⚔ Ride out to the Keep or read the rules.