Order 109 disappeared from the warehouse’s list
See the problem
Section titled “See the problem”The warehouse screen shows the next three orders to pack: the oldest orders that are still placed. Behind it is this query:
SELECT order_id, customer_id, order_date, totalFROM ordersWHERE status = 'placed'LIMIT 3;For weeks it shows orders 109, 110 and 111, the oldest three. Then support corrects order 109’s total, from 13.19 to 13.20. Nothing else changes. The next time the screen loads, it shows:
| order_id | customer_id | order_date | total |
|---|---|---|---|
| 110 | 2 | 2026-10-14 | 59.00 |
| 111 | 5 | 2026-10-15 | 54.00 |
| 113 | 6 | 2026-10-18 | 49.98 |
Order 109, placed on 14 October, is gone. Nobody packs it, and Ben waits. A week later, a phone order typed in late with the date 12 October never shows up at all.
The query didn’t change. So why did the list?
Define the task
Section titled “Define the task”Write the warehouse list: orders whose status is 'placed', with order_id, customer_id, order_date and total.
- Oldest first, by
order_date. - Two orders from the same day go lower
order_idfirst. - Only the first three.
- The list must be the same however Postgres stores the rows: after an update, and when an older order is typed in late.
Pause and think
Section titled “Pause and think”Hints open one at a time:
Hint 1 of 3
With no ORDER BY, Postgres gives rows in whatever order is quickest. For a small table, that is the order they sit in the table on disk. It is not the order of order_id.
Hint 2 of 3
An UPDATE in Postgres writes a new copy of the row, usually at the end of the table. So order 109 moved from first to last.
Hint 3 of 3
LIMIT 3 keeps the first three rows it gets, whichever they are. Only ORDER BY says which rows come first. Sort by order_date, then by order_id for orders from the same day.
Understand the idea
Section titled “Understand the idea”You write a query in one order, and the database runs it in another. You write SELECT, FROM, WHERE, ORDER BY, LIMIT. Postgres runs FROM first and LIMIT last:
| Runs | Clause | What it does |
|---|---|---|
| 1 | FROM orders | Reads the table’s rows |
| 2 | WHERE status = 'placed' | Keeps the rows where the condition is true |
| 3 | SELECT order_id, order_date | Picks the columns, and can work out new ones |
| 4 | ORDER BY order_date, order_id | Sorts the rows. Smallest first (ASC) unless you say DESC |
| 5 | LIMIT 3 | Keeps the first 3 rows and stops |
That order explains two rules. ORDER BY can use a name you made in SELECT on its own (total * 2 AS doubled, then ORDER BY doubled, but not ORDER BY doubled + 1), because it runs after SELECT. WHERE can’t, because it runs before.
Rows have no order until ORDER BY gives them one. A table is a set of rows, not a list. With no ORDER BY, Postgres gives rows in whatever order is quickest. For a small table that’s usually the order they sit on disk, which isn’t order_id order:
- An
UPDATEwrites a new copy of the row, usually at the end. The old copy is cleaned up later. - A deleted row leaves a gap, and a new row can fill it.
- On a big table, an index or several workers reading at once can change the order again.
So a query that “always” came back sorted can change any day, with no change to the query.
LIMIT only stops early. It keeps the first n rows, whichever they are. After ORDER BY, that’s the top n. Without it, it’s any n.
A sort needs a tie-breaker. ORDER BY order_date says nothing about two orders from the same day. Postgres may give 109 before 110 today, and 110 before 109 after an update. Add a column that is different for every row, like the primary key: ORDER BY order_date, order_id. Now every row has exactly one place.
Watch the warehouse query run on the orders after the update, one step at a time. Then type your own rows, in the order you want them stored:
- FROM orders
- WHERE status = 'placed'
- SELECT order_id, order_date
- ORDER BY order_date, order_id
- LIMIT 3
| order_id | order_date | status |
|---|---|---|
| 108 | 2026-10-11 | shipped |
| 110 | 2026-10-14 | placed |
| 111 | 2026-10-15 | placed |
| 112 | 2026-10-16 | delivered |
| 113 | 2026-10-18 | placed |
| 114 | 2026-10-20 | placed |
| 109 | 2026-10-14 | placed |
Step 1 of 7. FROM orders reads all 7 rows, in the order they’re stored. That isn’t order_id order: a changed row is written again at the end.
SELECT order_id, order_date FROM orders WHERE status = 'placed' ORDER BY order_date, order_id LIMIT 3;
WHERE keeps a row only when its condition is true.
- Compare with
=,<>(not equal),<,<=,>and>=. Text compares letter by letter, and capitals count:'Placed'isn’t'placed'. - Join conditions with
AND,ORandNOT.ANDgoes first, like×before+, so add brackets when you mix them. status IN ('placed', 'shipped')is a short way to write two=joined byOR.total BETWEEN 10 AND 20includes both ends.email LIKE '%@example.com'matches a pattern:%is any run of characters, and_is exactly one.= NULLnever matches. UseIS NULL. The lesson onNULLshows why.
SELECT picks the columns. SELECT * gives every column, which is useful for a quick look. In an app, name the columns, so a new column doesn’t change what the app gets. AS gives a column a new name: SELECT total AS amount.
Climb the ladder
Section titled “Climb the ladder”All three keep the placed orders and take three. What changes is which three, and in what order.
No ORDER BY
The idea: Keep the placed orders, then take the first three.
It looks right because the store’s rows were added oldest first, so they sit on disk in date order.
SELECT order_id, customer_id, order_date, totalFROM ordersWHERE status = 'placed'LIMIT 3;- Gets right
The store’s data, by luck- Gets wrong
Any change to the order rows are stored in- Why
- Without ORDER BY, LIMIT keeps the first three rows Postgres happens to read.
Where it breaks: after the update, order 109 sits last, so the list is 110, 111 and 113. A late phone order dated 12 October sits last too, so it never makes the list. Okay passes 1 of the 3 checks below.
ORDER BY order_date
The idea: Sort the placed orders oldest first, then take three.
The gap in Okay: nothing said which rows come first.
Now the late phone order comes first, and order 109 is back on the list.
Highlighted lines are new in this rung.
SELECT order_id, customer_id, order_date, totalFROM ordersWHERE status = 'placed'ORDER BY order_dateLIMIT 3;- Gets right
Which days make the listwas The store’s data, by luck- Gets wrong
Orders from the same day, in either order- Why
- 109 and 110 are both from 14 October, so ORDER BY order_date lets Postgres put either first.
What changed from the previous rung
SELECT order_id, customer_id, order_date, totalFROM ordersWHERE status = 'placed'ORDER BY order_dateLIMIT 3;Where it breaks: after the update, Postgres gives 110 before 109, so the pickers pack them in the wrong order. And when a tie falls on third place, which order makes the list at all is up to Postgres. Better passes 2 of 3.
ORDER BY order_date, order_id
The idea: Sort by date, and break ties with order_id, so every row has exactly one place.
The gap in Better: the sort had ties.
Highlighted lines are new in this rung.
SELECT order_id, customer_id, order_date, totalFROM ordersWHERE status = 'placed'ORDER BY order_date, order_idLIMIT 3;- Gets right
Every check, however the rows are storedwas Which days make the list- Gets wrong
Nothing the checks try- Why
- order_id is the primary key, so no two rows tie.
What changed from the previous rung
SELECT order_id, customer_id, order_date, totalFROM ordersWHERE status = 'placed'ORDER BY order_dateORDER BY order_date, order_idLIMIT 3;When Best isn’t best: to keep three rows, Postgres still sorts every placed order. That’s fine for a few thousand. For millions, an index on (order_date, order_id) lets Postgres read the oldest rows already in order and stop after three. The query stays the same; the lesson on indexes shows how.
Write it yourself
Section titled “Write it yourself”Now write it in the page. Press Run query to see your result and check it. Postgres runs in your browser, so nothing is sent anywhere. Each check changes a few rows first, then they’re put back.
Try Okay from the ladder first, and open a failed check to see what it changed. Then press Show the steps to watch your own query run.
The warehouse list
Ctrl + Enter runsThe tables
orders(order_id, customer_id, order_date, status, total)
- Not run yet: The store’s data (row order matters)
- Not run yet: A phone order is typed in late, dated 12 October (row order matters)
What this check changes
INSERT INTO orders (order_id, customer_id, order_date, status, total) VALUES (115, 3, '2026-10-12', 'placed', 20.00);
- Not run yet: Support corrects order 109’s total (row order matters)
What this check changes
UPDATE orders SET total = 13.20 WHERE order_id = 109;
Each check runs on its own copy of the data, so your query can’t change the real tables.
Compare
Section titled “Compare”| Way | Gets right | Gets wrong | Use it when |
|---|---|---|---|
Okay: no ORDER BY | Nothing it can promise | Which rows, and their order | Never with LIMIT; fine for a quick look at a table |
Better: ORDER BY order_date | Which days make the list | Ties: their order, and who makes the cut | The column is already unique |
Best: ORDER BY order_date, order_id | Every row has one place | Nothing the checks try | Always, with LIMIT or paging |
Common mistakes and edge cases
Section titled “Common mistakes and edge cases”- A row in your result isn’t proof of an order. A test on a small table can pass for years with no
ORDER BY. WriteORDER BYwhenever order matters. DESCbelongs to one column.ORDER BY signed_up_on DESC, customer_idsorts the dates newest first, but the ids smallest first. WriteDESCafter each column you want reversed.OFFSETreads and throws away.LIMIT 20 OFFSET 10000still reads 10,020 rows. And a row added while someone reads page 1 pushes a row from page 1 onto page 2. Practice problem 2 meets this.NULLsorts last inASCand first inDESC. SayNULLS FIRSTorNULLS LASTto choose.- Capitals matter in text.
'Placed','placed 'with a space, and'placed'are three different values. Comparelower(email)when case shouldn’t matter. _and%in aLIKEpattern are wildcards.'%_pro%'matches any character before “pro”. Write'%\_pro%'to mean a real underscore.ORDER BY 2sorts by the second column ofSELECT. It works, but it breaks quietly when someone adds a column. Name the column instead.
Say it in the interview
Section titled “Say it in the interview”Questions to ask first
- Oldest first by which date: when the order was placed, or when it was paid?
- Two orders from the same day: does the order between them matter?
- Is this one screen, or a list people page through?
- How many placed orders can there be at once?
A 60-second walkthrough
“I’d filter to status = ‘placed’, then ORDER BY order_date, order_id, and LIMIT 3. ORDER BY matters: without it, Postgres can return rows in any order, and in practice the order changes after updates, so LIMIT would keep a random three. The second sort key, order_id, breaks ties between orders from the same day. So the list is the same every time, and nobody’s order is skipped. If the table grows large, I’d add an index on order_date and order_id. Then the database reads the first three in order instead of sorting everything.”
Likely follow-ups
- What order does SQL run a query in?
FROM,WHERE,GROUP BY,HAVING,SELECT,ORDER BY,LIMIT. - Why can
ORDER BYuse aSELECTalias butWHEREcan’t?ORDER BYruns afterSELECT, andWHEREruns before it. - How do you page through a big table? Not with a big
OFFSET. Remember the last row’s sort values and ask for rows after them:WHERE (signed_up_on, customer_id) < (...). That’s keyset paging. - Is
INfaster thanOR? Not so you’d notice: Postgres turnsIN (...)into= ANY (...), one check against a list. PickINbecause it’s easier to read.
Practice
Section titled “Practice”Three problems, from easy to medium. Each has hints, checks you can run here, and its own Okay, Better and Best answers. Try before you open them.
1. Cheap stationery and home products (easy)
Section titled “1. Cheap stationery and home products (easy)”The “gifts under 10” page lists products in the stationery or home category that cost less than 10. Show product_id, name, category and price, cheapest first. Two products with the same price go lower product_id first.
Hint 1 of 3
Two conditions: the category is one of two, and the price is under 10. Both must be true.
Hint 2 of 3
AND runs before OR. Without brackets, the price rule only applies to home.
Hint 3 of 3
category IN (...) AND price < 10, then ORDER BY price, product_id.
Cheap stationery and home products
Ctrl + Enter runsThe tables
products(product_id, name, category, price)
- Not run yet: The store’s data (row order matters)
- Not run yet: A 35.00 fountain pen joins stationery (row order matters)
What this check changes
INSERT INTO products (product_id, name, category, price) VALUES (9, 'Fountain pen', 'stationery', 35.00);
- Not run yet: Notebooks go on sale at 3.20, the same price as the gel pens (row order matters)
What this check changes
UPDATE products SET price = 3.20 WHERE product_id = 1;
Each check runs on its own copy of the data, so your query can’t change the real tables.
Show the Okay, Better and Best answers
OR and AND with no brackets
The idea: Write the conditions as you’d say them.
Okay passes 2 of 3. A 35.00 fountain pen shows up on the gifts under 10 page.
SELECT product_id, name, category, priceFROM productsWHERE category = 'stationery' OR category = 'home' AND price < 10ORDER BY price, product_id;- Gets right
The store’s data: every stationery item is cheap- Gets wrong
Stationery at 10 or more- Why
- AND runs first, so this is stationery at any price, or home under 10.
Brackets, but no tie-breaker
The idea: Brackets make the OR happen first.
The gap in Okay: the price rule only covered home.
Highlighted lines are new in this rung.
SELECT product_id, name, category, priceFROM productsWHERE (category = 'stationery' OR category = 'home') AND price < 10ORDER BY price;- Gets right
Which products make the page- Gets wrong
Two products at the same price, in either order- Why
- ORDER BY price alone leaves ties in any order.
What changed from the previous rung
SELECT product_id, name, category, priceFROM productsWHERE category = 'stationery' OR category = 'home' AND price < 10ORDER BY price, product_id;WHERE (category = 'stationery' OR category = 'home') AND price < 10ORDER BY price;Where it breaks: when notebooks go on sale at 3.20, the gel pens’ price, Postgres puts the gel pens first. Better passes 2 of 3.
IN, and a tie-breaker
The idea: IN says one of these, with no brackets to forget, and product_id breaks ties.
The gap in Better: ties at the same price.
Highlighted lines are new in this rung.
SELECT product_id, name, category, priceFROM productsWHERE category IN ('stationery', 'home') AND price < 10ORDER BY price, product_id;- Gets right
Every check- Gets wrong
Nothing the checks try- Why
- IN can’t mix with AND the wrong way, and product_id is unique.
What changed from the previous rung
SELECT product_id, name, category, priceFROM productsWHERE (category = 'stationery' OR category = 'home') AND price < 10ORDER BY price;WHERE category IN ('stationery', 'home') AND price < 10ORDER BY price, product_id;When Best isn’t best: if shoppers pick the sort (“price, high to low”), build the ORDER BY from a fixed list of choices, and keep product_id last in each.
2. The next page of customers (medium)
Section titled “2. The next page of customers (medium)”The admin screen lists customers, newest sign-up first, three to a page. Two customers who signed up on the same day go higher customer_id first. Page 1 showed Hugo, Grace and Farid; the last row was Farid, who signed up on 2026-05-02 and is customer 6. Show page 2: customer_id, name and signed_up_on. It must start right after Farid, even if someone signs up while page 1 is on screen.
Hint 1 of 3
Newest first is ORDER BY signed_up_on DESC. Which column breaks a tie, and which way?
Hint 2 of 3
OFFSET 3 skips three rows. If a new customer signs up, which three are those now?
Hint 3 of 3
Ask for the rows that sort after Farid: WHERE (signed_up_on, customer_id) < (Farid’s date, Farid’s id), with the same ORDER BY, then LIMIT 3.
The next page of customers
Ctrl + Enter runsThe tables
customers(customer_id, name, email, city, signed_up_on)
- Not run yet: The store’s data (row order matters)
- Not run yet: Ben’s sign-up date is corrected to 20 February, the same day as Chen (row order matters)
What this check changes
UPDATE customers SET signed_up_on = '2026-02-20' WHERE customer_id = 2;
- Not run yet: Jon signs up while page 1 is on screen (row order matters)
What this check changes
INSERT INTO customers (customer_id, name, email, city, signed_up_on) VALUES (9, 'Jon Berg', 'jon@example.com', 'Oslo', '2026-11-07');
Each check runs on its own copy of the data, so your query can’t change the real tables.
Show the Okay, Better and Best answers
OFFSET, with ties
The idea: Sort newest first, skip page 1, take three.
Okay passes 1 of 3. When Ben’s date is corrected to Chen’s, Postgres puts Ben on page 2 instead of Chen.
SELECT customer_id, name, signed_up_onFROM customersORDER BY signed_up_on DESCLIMIT 3 OFFSET 3;- Gets right
The store’s data: no two sign-ups share a day- Gets wrong
A tie at the end of the page; a new sign-up- Why
- With a tie at the cut, either customer can land on page 2.
OFFSET, with a tie-breaker
The idea: Add customer_id DESC, so every customer has one place.
The gap in Okay: ties at the cut.
Highlighted lines are new in this rung.
SELECT customer_id, name, signed_up_onFROM customersORDER BY signed_up_on DESC, customer_id DESCLIMIT 3 OFFSET 3;- Gets right
Ties- Gets wrong
Anyone who signs up while page 1 is on screen- Why
- OFFSET counts rows, and a new row at the top shifts every row down one.
What changed from the previous rung
SELECT customer_id, name, signed_up_onFROM customersORDER BY signed_up_on DESCORDER BY signed_up_on DESC, customer_id DESCLIMIT 3 OFFSET 3;Where it breaks: Jon signs up while page 1 is on screen. Jon is now the newest, so OFFSET 3 skips Jon, Hugo and Grace, and Farid shows up again on page 2. Better passes 2 of 3.
Start after the last row (keyset paging)
The idea: Remember the last row’s date and id, and ask for the rows that sort after it.
The gap in Better: the page started at a count, not at a row.
It’s faster too: with an index on (signed_up_on, customer_id), Postgres jumps straight to Farid, where OFFSET 10000 would read and throw away 10,000 rows.
Highlighted lines are new in this rung.
SELECT customer_id, name, signed_up_onFROM customersWHERE (signed_up_on, customer_id) < ('2026-05-02', 6)ORDER BY signed_up_on DESC, customer_id DESCLIMIT 3;- Gets right
Ties, and new sign-ups- Gets wrong
Nothing the checks try- Why
- (a, b) < (x, y) compares a first, and b only when a ties, just like the ORDER BY.
What changed from the previous rung
SELECT customer_id, name, signed_up_onFROM customersWHERE (signed_up_on, customer_id) < ('2026-05-02', 6)ORDER BY signed_up_on DESC, customer_id DESCLIMIT 3 OFFSET 3;LIMIT 3;When Best isn’t best: keyset paging can’t jump to page 50; it only goes to the next page. When people need page numbers on a small list, OFFSET with a tie-breaker is fine.
3. Customers with an example.com email (medium)
Section titled “3. Customers with an example.com email (medium)”Find the customers whose email is at the domain example.com, whatever the capitals. Show customer_id, name and email, by customer_id. An address like ivy@notexample.com isn’t at example.com.
Hint 1 of 3
LIKE matches a pattern: % is any run of characters. Where must the pattern start, so notexample.com doesn’t match?
Hint 2 of 3
Put the @ in the pattern: %@example.com.
Hint 3 of 3
LIKE cares about capitals. Compare lower(email) instead.
Customers with an example.com email
Ctrl + Enter runsThe tables
customers(customer_id, name, email, city, signed_up_on)
- Not run yet: The store’s data (row order matters)
- Not run yet: Ivy signs up as ivy@notexample.com (row order matters)
What this check changes
INSERT INTO customers (customer_id, name, email, city, signed_up_on) VALUES (9, 'Ivy Nair', 'ivy@notexample.com', 'Kochi', '2026-08-01');
- Not run yet: Jon’s email is saved in capitals: JON@EXAMPLE.COM (row order matters)
What this check changes
INSERT INTO customers (customer_id, name, email, city, signed_up_on) VALUES (9, 'Jon Berg', 'JON@EXAMPLE.COM', 'Oslo', '2026-08-02');
Each check runs on its own copy of the data, so your query can’t change the real tables.
Show the Okay, Better and Best answers
Ends with example.com
The idea: LIKE '%example.com': anything, then example.com.
Okay passes 1 of 3. Ivy, at notexample.com, is counted, and Jon, at EXAMPLE.COM, is missed.
SELECT customer_id, name, emailFROM customersWHERE email LIKE '%example.com'ORDER BY customer_id;- Gets right
The store’s data- Gets wrong
notexample.com, and capitals- Why
- % matches any characters, including the “not” in notexample.com.
Ends with @example.com
The idea: Put the @ in the pattern, so the domain must be exactly example.com.
The gap in Okay: the pattern didn’t say where the domain starts.
Highlighted lines are new in this rung.
SELECT customer_id, name, emailFROM customersWHERE email LIKE '%@example.com'ORDER BY customer_id;- Gets right
Other domains that end the same way- Gets wrong
Capitals- Why
- LIKE compares letters exactly, so E isn’t e.
What changed from the previous rung
SELECT customer_id, name, emailFROM customersWHERE email LIKE '%example.com'WHERE email LIKE '%@example.com'ORDER BY customer_id;Where it breaks: Jon’s email is saved as JON@EXAMPLE.COM, and LIKE '%@example.com' misses it. Better passes 2 of 3.
lower(email)
The idea: Make the email small letters first, then match.
The gap in Better: capitals.
email ILIKE '%@example.com' does the same in Postgres. ILIKE isn’t standard SQL; lower() works in every database.
Highlighted lines are new in this rung.
SELECT customer_id, name, emailFROM customersWHERE lower(email) LIKE '%@example.com'ORDER BY customer_id;- Gets right
Every check- Gets wrong
Nothing the checks try- Why
- lower() turns JON@EXAMPLE.COM into jon@example.com before LIKE compares.
What changed from the previous rung
SELECT customer_id, name, emailFROM customersWHERE email LIKE '%@example.com'WHERE lower(email) LIKE '%@example.com'ORDER BY customer_id;When Best isn’t best: a pattern that starts with % can’t use a plain index, so Postgres reads every customer. If the app often searches by domain, store the domain in its own column, saved in small letters.
Check yourself
Section titled “Check yourself”A few quick questions. Ones you miss are remembered, so you can find your weak spots later.
Flashcards
Section titled “Flashcards”Add these to your review deck. Each card comes back a little later every time you get it right.
Cheat sheet
Section titled “Cheat sheet”- Written:
SELECT,FROM,WHERE,ORDER BY,LIMIT. Run:FROM,WHERE,SELECT,ORDER BY,LIMIT. - No
ORDER BYmeans no promised order, and it can change after any update. LIMIT nkeeps the first n rows. Use it withORDER BY, and break ties with a unique column.DESCreverses one column.NULLsorts last inASC, first inDESC.ANDbeforeOR: use brackets orIN.BETWEENincludes both ends.LIKE:%is any run of characters,_is one. Capitals count; comparelower(col).- Page with
WHERE (a, b) < (last a, last b), not a bigOFFSET.
Go deeper
Section titled “Go deeper”- In the PostgreSQL docs: SELECT, sorting rows, LIMIT and OFFSET and pattern matching.
- Try your own rows: the warehouse list on the SQL visualizer.
- Next on the SQL roadmap: the whole map, from your first
SELECTto window functions.
Can you do these now?
If you’re not sure of any of these, scroll back to that part before moving on.
Status: Not started