Menu PLEX MONARCHYSQL QUESTS · LUCIDPLEXUS

The SQL guide

Everything you need to write answers in the Keep. The Keep speaks SQLite. Every example on this page is run against the real ledger when the page loads — the tables under the code are its actual results.

0Game terminology

The names below are the game's vocabulary. They describe the learning experience; SQL itself follows SQLite syntax.

The Keep
The quest-playing environment.
Cartridge
A dated cycle of quests arranged by day.
The Ledger
The read-only SQLite dataset used by queries.
The Steward
The judge that grades submitted results.
Candle & Lives
The quest timer and remaining graded attempts.
Lantern
A hint that costs the displayed amount of XP reward.
The Court
Quest and weekly leaderboards.
The Throne Room
Weekly and all-time standings and titles.

IHow the Keep reads SQL

RUN
Free, as often as you like. Try things.
SUBMIT
Your answer, graded. Costs a life.
200
Rows returned at most
4,000
Characters per query at most

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

ColumnType
Receivable AmountTEXT
Payable AmountTEXT
Due DateTEXT
Payment StatusTEXT

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
Refused: sql_errorTwo columns, one renamed

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
Refused: sql_errorEach product once

IVFiltering rows

WHERE keeps only the rows where the condition is true.

WriteMeans
= <> < >=equal, not equal, less than, at least
AND OR NOTcombine conditions — use brackets when you mix AND and OR
IN ('a', 'b')matches any value in the list
BETWEEN 10 AND 2010 to 20, both ends included
LIKE 'Sa%'text pattern: % any run of characters, _ exactly one
IS NULLthe value is missing (never = NULL)
SELECT id, product, qty FROM order_items WHERE product IN ('Amber', 'Saffron') AND qty >= 2
Refused: sql_errorTwo products, at least two units

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
Refused: sql_errorThe three highest prices, ties broken by name
Without 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
Refused: sql_errorCustomers per region
SELECT product, SUM(qty * unit_price) AS takings FROM order_items GROUP BY product HAVING SUM(qty * unit_price) > 1000 ORDER BY takings DESC
Refused: sql_errorProducts that took more than 1,000
Every column in the 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
Refused: sql_errorEach customer with their region

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
Refused: sql_errorEvery region, even an empty one, with its customer count

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
Refused: sql_errorCompared with an average worked out over the whole table
SELECT name FROM regions WHERE id IN (SELECT region_id FROM customers WHERE joined_on >= '2026-01-01') ORDER BY name
Refused: sql_errorRegions with a customer who joined this year

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
Refused: sql_errorA named step, used twice
Recursive CTEs (a CTE that reads from itself, with or without the word 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.

FunctionGives
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
Refused: sql_errorEach product’s lines numbered from biggest
SELECT joined_on, name, COUNT(*) OVER (ORDER BY joined_on) AS members_so_far FROM customers ORDER BY joined_on
Refused: sql_errorA running count

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
Refused: sql_errorLabels from a number

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
Refused: sql_errorCustomers joining per 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

WriteGives
a || ' ' || bjoined 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
wholeexactrounded
33.52.33
Whole numbers divide to a whole number1 row
Dividing two whole numbers drops the remainder: 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.

XVHow answers are graded

When you SUBMIT, the Steward runs the quest's own answer on the same ledger and compares the two results.

CheckedHow
Row countmust match exactly
Columnscompared by position — the names and aliases you choose do not matter, the order does
Row ordermatters only when the quest asks for an order (“highest first”, “alphabetical”); otherwise any order is fine
Numberscompared to 4 decimal places, so 12, 12.0 and 12.00001 are equal
Textcompared exactly, except that surrounding spaces and repeated spaces are ignored
NULLonly matches NULL — not 0, not '', not the word 'NULL'
The ledgeryour answer must read at least one table; typing the answer in as values is refused (no life spent)
Stuck? RUN costs nothing. Look at the tables, check your row count, then submit.

XVIWhat is not allowed

RefusedWhy
Anything but SELECT / WITHthe ledger is read-only
Two statements, ; in the middleone answer per submit
Comments -- /* */they hide text from the checks
Recursive CTEsa single one can run forever
More than 5 tables in one querycross joins grow as rowsN
printf widths over 99, randomblob, zeroblobthey build huge values from a short query
sqlite_master, pragma_…the schema is on this page instead
Parameters ? :namethere is nothing to bind them to

A refused query never costs a life.

XVIIMessages you may see

MessageWhat to do
The wrong number of rows came backcheck your filters, joins and LIMIT; a join can duplicate rows
Right rows, wrong orderadd or fix ORDER BY
Those are not the rows the Steward wantscompare column by column with what the quest asks, in that order
The ledger could not parse thatfirst 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 thatSQLite 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 ledgerquery the tables rather than typing values
Too many tables joinedsimplify; at most 5
The candle has gone outthe quest's time ran out; the next day brings a new one

⚔ Ride out to the Keep   or read the rules.

Plex Monarchy · The Gates · The Throne Room