Visualize SQL
A query isn’t run top to bottom. The database starts with FROM, then WHERE, then window functions, then SELECT, then ORDER BY. Each step below shows the table at that point. All visualizers.
- Your own queryWrite any SELECT and watch it run clause by clause, with the table after each step.Roadmap: Logical query processing order, GROUP BY and HAVING, Joins: inner, left, right, full, self and cross
- Rows checked against keysWatch new rows go into a table with a primary key, foreign keys and UNIQUE, one rule at a time.Lesson: My report shows reviews of a product we don’t sell · Roadmap: Tables, rows and keys
- The warehouse listWatch WHERE, ORDER BY and LIMIT pick the oldest orders, and see what LIMIT keeps with no ORDER BY.Lesson: Order 109 disappeared from the warehouse’s list · Roadmap: SELECT, WHERE, ORDER BY and LIMIT, Logical query processing order
- The second-highest priceWatch DISTINCT keep one row per price, so OFFSET 1 skips the top price and not just one copy of it.Lesson: Our “second-highest price” is the highest price · Roadmap: DISTINCT, Logical query processing order
- The sales tileWatch count, sum and avg fold one day’s orders into one row, even when no rows are left.Lesson: The sales tile says two customers ordered, but it was only Hugo · Roadmap: Aggregates, Logical query processing order
- The loyalty couponWatch WHERE drop rows, GROUP BY make groups, and HAVING drop whole groups.Lesson: Grace shopped on one day and got the loyalty coupon · Roadmap: GROUP BY and HAVING, Logical query processing order
- The reminder listWatch one NULL in a NOT IN list make every comparison unknown, so no customer gets the reminder.Lesson: A guest checked out, and nobody got a reminder · Roadmap: NULL and three-valued logic, Simple subqueries
- The payments reportWatch an inner JOIN drop an order with no payment, LEFT JOIN keep it, and GROUP BY fold a split payment.Lesson: Dana never paid, and finance never noticed · Roadmap: Joins: inner, left, right, full, self and cross, GROUP BY and HAVING
- The double-order checkWatch a self join pair each order with itself and every pair both ways, then keep each pair once and check both orders.Lesson: Grace cancelled, and we still asked if she’d ordered twice · Roadmap: Joins: inner, left, right, full, self and cross
- The bottle offerWatch two subqueries list who bought a backpack and who bought a water bottle. Then IN keeps each customer once, and NOT IN drops the rest.Lesson: Elif bought two backpacks and got the bottle offer twice · Roadmap: Simple subqueries
- The duplicate cleanupWatch GROUP BY gather the payments that match in every column but the id, keep the first of each, and DELETE remove the rest.Lesson: We deleted Ben’s duplicate payment, and the real one went too · Roadmap: INSERT, UPDATE and DELETE
- The welcome listWatch a query run in the order the database runs it: WHERE drops cancelled orders, then HAVING tests each customer’s first order.Lesson: Dana first ordered in August and got September’s welcome email · Roadmap: Logical query processing order
- The pay listWatch WITH name the staff past probation once, then the company average and each department read the same rows.Lesson: A team of one made the above-average pay list · Roadmap: CTEs
- The growing ordersWatch a correlated subquery run once for each order, and pick the same customer’s order just before it.Lesson: Bigger than the order before, or just typed in late? · Roadmap: Correlated subqueries
- The window shoppersWatch EXISTS stop at the first match, and NOT EXISTS ignore a guest’s NULL that empties NOT IN.Lesson: Grace got the window-shopper email twice · Roadmap: EXISTS vs IN vs JOIN, and the NOT IN + NULL trap
- Window functionsWatch a query run in the order the database runs it, numbering each customer’s orders.Lesson: Each customer's latest order · Roadmap: Window functions: ranking, LAG, LEAD, running totals, frames
Your own query
Section titled “Your own query”Write any SELECT on the store’s tables and press Show the steps. Each step shows the table so far: rows kept ✓ or dropped ✗, groups and partitions shaded in turn, and the line of your query that runs. The last step is checked against what Run gives.
Write a query, then watch it run
Ctrl + Enter shows the stepsThe tables
customers(customer_id, name, email, city, signed_up_on)products(product_id, name, category, price)orders(order_id, customer_id, order_date, status, total)order_items(order_id, product_id, quantity, unit_price)payments(payment_id, order_id, paid_on, method, amount)departments(department_id, name)employees(employee_id, name, department_id, manager_id, salary, hired_on)events(event_id, customer_id, event_type, occurred_at)
Under every SQL editor in the lessons, Show the steps does the same.
Rows checked against keys
Section titled “Rows checked against keys”New rows go into reviews, which has a primary key, two foreign keys and UNIQUE (customer_id, product_id). Postgres checks each rule in turn, and the first that says no rejects the row. Type your own rows: a repeated review_id, a product that doesn’t exist, a second review of the same product. The lesson: My report shows reviews of a product we don’t sell.
- PRIMARY KEY
- UNIQUE (customer_id, product_id)
- REFERENCES products
- REFERENCES customers
| product_id | name |
|---|---|
| 1 | Notebook |
| 2 | Gel pen (5 pack) |
| 3 | Desk lamp |
| 4 | Water bottle |
| 5 | Wireless mouse |
| 6 | USB-C cable |
| 7 | Headphones |
| 8 | Backpack |
| customer_id | name |
|---|---|
| 1 | Asha Rao |
| 2 | Ben Okafor |
| 3 | Chen Wei |
| 4 | Dana Cohen |
| 5 | Elif Demir |
| 6 | Farid Haddad |
| 7 | Grace Kim |
| 8 | Hugo Silva |
| review_id | product_id | customer_id |
|---|
Step 1 of 13. reviews starts empty. Each new row must pass 4 rules before it goes in, in the order listed above. The first rule that says no rejects the row.
CREATE TABLE reviews ( review_id integer PRIMARY KEY, product_id integer NOT NULL REFERENCES products, customer_id integer NOT NULL REFERENCES customers, stars integer NOT NULL, body text, UNIQUE (customer_id, product_id) );
The warehouse list
Section titled “The warehouse list”The 3 oldest placed orders: WHERE keeps the placed ones, ORDER BY sorts them by date and then by order_id, and LIMIT keeps the first 3. Rows come in the order they’re stored, so see which 3 LIMIT would keep with no ORDER BY. Type your own rows, in any stored order. The lesson: Order 109 disappeared from the warehouse’s list.
- FROM orders
- WHERE status = 'placed'
- SELECT order_id, order_date
- ORDER BY order_date, order_id
- LIMIT 3
| order_id | order_date | status |
|---|---|---|
| 101 | 2026-10-02 | placed |
| 104 | 2026-10-05 | shipped |
| 103 | 2026-10-04 | placed |
| 102 | 2026-10-04 | placed |
| 105 | 2026-10-01 | placed |
Step 1 of 7. FROM orders reads all 5 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;
The second-highest price
Section titled “The second-highest price”DISTINCT keeps one row for each different price, ORDER BY price DESC puts the highest first, and LIMIT 1 OFFSET 1 skips it and keeps the next. With the top price twice and no DISTINCT, the second row would be the top price again. Type your own products, with prices that repeat, or all the same. The lesson: Our “second-highest price” is the highest price.
- FROM products
- SELECT price
- DISTINCT
- ORDER BY price DESC
- OFFSET 1
- LIMIT 1
- SELECT (…) AS second_price
| name | price |
|---|---|
| Desk lamp | 24.99 |
| Water bottle | 12.00 |
| Lamp shade | 24.99 |
| Notebook | 4.50 |
Step 1 of 7. FROM products reads all 4 rows.
SELECT ( SELECT DISTINCT price FROM products ORDER BY price DESC LIMIT 1 OFFSET 1 ) AS second_price;
The sales tile
Section titled “The sales tile”WHERE keeps one day’s orders, then count, sum and avg fold them into one row. count(DISTINCT customer_id) counts a customer who ordered twice once. With no orders left, the query still gives one row: count gives 0, sum and avg give NULL, and coalesce turns the sum into 0. Type your own orders, with one customer twice, or none on 20 October. The lesson: The sales tile says two customers ordered, but it was only Hugo.
- FROM orders
- WHERE order_date = '2026-10-20'
- count(*)
- count(DISTINCT customer_id)
- coalesce(sum(total), 0)
- round(avg(total), 2)
| customer_id | order_date | total |
|---|---|---|
| 3 | 2026-10-20 | 12.50 |
| 3 | 2026-10-20 | 30.00 |
| 5 | 2026-10-20 | 7.25 |
| 4 | 2026-10-19 | 18.00 |
Step 1 of 7. FROM orders reads all 4 rows.
SELECT count(*) AS orders, count( DISTINCT customer_id) AS customers, coalesce( sum( total), 0) AS sales, round( avg( total), 2) AS avg_order FROM orders WHERE order_date = '2026-10-20';
The loyalty coupon
Section titled “The loyalty coupon”WHERE drops the cancelled orders, GROUP BY customer_id makes one group for each customer, and HAVING keeps the groups with orders on 3 or more different days. Three orders on one day count as one day. Type your own orders, with a cancelled one, or several on one day. The lesson: Grace shopped on one day and got the loyalty coupon.
- FROM orders
- WHERE status <> 'cancelled'
- GROUP BY customer_id
- HAVING count(DISTINCT order_date) >= 3
- SELECT customer_id
- ORDER BY customer_id
| customer_id | order_date | status |
|---|---|---|
| 3 | 2026-10-01 | delivered |
| 3 | 2026-10-08 | shipped |
| 3 | 2026-10-08 | placed |
| 3 | 2026-10-15 | placed |
| 4 | 2026-10-02 | cancelled |
| 4 | 2026-10-09 | placed |
| 4 | 2026-10-12 | placed |
| 5 | 2026-10-20 | placed |
| 5 | 2026-10-20 | placed |
| 5 | 2026-10-20 | placed |
Step 1 of 6. FROM orders reads all 10 rows.
SELECT customer_id FROM orders WHERE status <> 'cancelled' GROUP BY customer_id HAVING count(DISTINCT order_date) >= 3 ORDER BY customer_id;
The reminder list
Section titled “The reminder list”The subquery lists the customers who checked out. A guest checks out without signing in, so their customer_id is NULL. NOT IN compares each customer with every value in the list, and a comparison with NULL is unknown. So with a NULL in the list, no customer is kept. AND customer_id IS NOT NULL takes it out. Type your own checkouts, with a NULL or without one. The lesson: A guest checked out, and nobody got a reminder.
- FROM events
- WHERE event_type = 'checkout'
- AND customer_id IS NOT NULL
- FROM customers
- WHERE customer_id NOT IN (…)
- SELECT customer_id, name
- ORDER BY customer_id
| customer_id |
|---|
| 1 |
| 3 |
| 3 |
| 6 |
| NULL |
| 8 |
Step 1 of 6. The subquery runs first. It reads the 6 checkouts: one is a guest’s, whose customer_id is NULL.
SELECT customer_id, name FROM customers WHERE customer_id NOT IN ( SELECT customer_id FROM events WHERE event_type = 'checkout' AND customer_id IS NOT NULL ) ORDER BY customer_id;
The payments report
Section titled “The payments report”LEFT JOIN pairs each order with each of its payments. An order with no payment stays once, with NULL for the amount, where an inner JOIN would drop it. An order paid in parts is on one row per payment, until GROUP BY folds them and sum adds them up. Type your own payments, with an order paid in three parts, or one for the cancelled order. The lesson: Dana never paid, and finance never noticed.
- FROM orders
- LEFT JOIN payments
- WHERE status <> 'cancelled'
- GROUP BY order_id, total
- SELECT …, coalesce(sum(amount), 0)
- ORDER BY order_id
| order_id | amount |
|---|---|
| 112 | 9.99 |
| 113 | 20.00 |
| 113 | 29.98 |
| 114 | 142.99 |
| order_id | status | total |
|---|---|---|
| 107 | cancelled | 136.00 |
| 112 | delivered | 9.99 |
| 113 | placed | 49.98 |
| 114 | placed | 142.99 |
| 115 | placed | 24.99 |
Step 1 of 7. FROM orders reads 5 orders. Beside them is the payments table, with 4 payments. Question: Order 113 has two payments. After the join, how many rows is order 113 on?
What happens next? Order 113 has two payments. After the join, how many rows is order 113 on?
SELECT o.order_id, o.total, coalesce(sum( p.amount), 0) AS paid FROM orders o LEFT JOIN payments p ON p.order_id = o.order_id WHERE o.status <> 'cancelled' GROUP BY o.order_id, o.total ORDER BY o.order_id;
The double-order check
Section titled “The double-order check”A self join reads orders twice, as a and as b, and pairs orders of the same customer. Each order pairs with itself, and each pair shows up twice, until a.order_id < b.order_id keeps it once. Then a and b are checked on their own: a cancelled order on either side drops the pair. Type your own day of orders, with three from one customer, or a cancelled one. The lesson: Grace cancelled, and we still asked if she’d ordered twice.
- FROM orders a
- JOIN orders b ON …
- WHERE a.order_id < b.order_id
- AND a.status <> 'cancelled'
- AND b.status <> 'cancelled'
- SELECT
- ORDER BY
| order_id | customer_id | status |
|---|---|---|
| 115 | 5 | placed |
| 116 | 5 | placed |
| 117 | 5 | placed |
| 118 | 8 | cancelled |
| 119 | 8 | placed |
Step 1 of 7. FROM orders reads 5 orders, all from 21 October, so order_date always matches. The query reads orders a second time, as b, to pair them up. Question: Customer 5 has three orders. The join pairs each of them, as a, with each of them, as b. How many rows does customer 5 get?
What happens next? Customer 5 has three orders. The join pairs each of them, as a, with each of them, as b. How many rows does customer 5 get?
SELECT a.customer_id, a.order_id AS order_1, b.order_id AS order_2 FROM orders a JOIN orders b ON b.customer_id = a.customer_id AND b.order_date = a.order_date WHERE a.order_id < b.order_id AND a.status <> 'cancelled' AND b.status <> 'cancelled' ORDER BY a.customer_id, a.order_id, b.order_id;
The bottle offer
Section titled “The bottle offer”Two subqueries run first: one lists the customers with a backpack order that wasn’t cancelled, the other the ones with a water bottle. Then IN keeps each customer on the first list, once however often they’re on it, and NOT IN drops the ones on the second. Type your own orders, with three backpacks for one customer, or a cancelled one. The lesson: Elif bought two backpacks and got the bottle offer twice.
- First subquery: backpacks
- Second subquery: water bottles
- FROM customers
- WHERE customer_id IN (…)
- AND customer_id NOT IN (…)
- SELECT customer_id, name
- ORDER BY customer_id
| Kept? | customer_id | product_id | status |
|---|---|---|---|
| kept | 2 | 8 | placed |
| kept | 2 | 8 | shipped |
| dropped | 4 | 4 | cancelled |
| kept | 4 | 8 | placed |
| dropped | 6 | 8 | cancelled |
| dropped | 6 | 4 | delivered |
Step 1 of 7. The first subquery runs first. Of the 6 orders, it keeps the backpacks (product 8) that weren’t cancelled. The backpack order from customer 6 is cancelled. Its list: (2, 2, 4).
SELECT customer_id, name FROM customers WHERE customer_id IN ( SELECT o.customer_id FROM orders o JOIN order_items i ON i.order_id = o.order_id WHERE i.product_id = 8 AND o.status <> 'cancelled' ) AND customer_id NOT IN ( SELECT o.customer_id FROM orders o JOIN order_items i ON i.order_id = o.order_id WHERE i.product_id = 4 AND o.status <> 'cancelled' ) ORDER BY customer_id;
The duplicate cleanup
Section titled “The duplicate cleanup”The subquery groups the payments that match in every column but payment_id, and keeps the smallest id of each group: the keep list. Then DELETE removes every payment that isn’t on it. Type your own payments, with three copies of one, or an order paid in two different parts. The lesson: We deleted Ben’s duplicate payment, and the real one went too.
- Subquery: FROM payments
- GROUP BY order_id, paid_on, method, amount
- SELECT min(payment_id)
- DELETE … WHERE payment_id NOT IN (…)
- The table after
| payment_id | order_id | method | amount |
|---|---|---|---|
| 14 | 118 | card | 24.99 |
| 15 | 118 | card | 24.99 |
| 16 | 118 | card | 24.99 |
| 17 | 119 | upi | 9.99 |
| 18 | 119 | wallet | 5.00 |
Step 1 of 5. The subquery runs first. FROM payments reads today’s 5 payments, all paid on 21 October. Question: Payments 14, 15 and 16 match in every column but payment_id. How many groups does GROUP BY order_id, paid_on, method, amount make of them?
What happens next? Payments 14, 15 and 16 match in every column but payment_id. How many groups does GROUP BY order_id, paid_on, method, amount make of them?
DELETE FROM payments WHERE payment_id NOT IN ( SELECT min(payment_id) FROM payments GROUP BY order_id, paid_on, method, amount );
The welcome list
Section titled “The welcome list”The query runs in the order the database runs it. WHERE drops cancelled orders before any groups exist. Then GROUP BY makes one group for each customer, and HAVING keeps those whose first order is in September. Type your own orders, like a customer whose first order was cancelled. The lesson: Dana first ordered in August and got September’s welcome email.
- FROM orders
- WHERE status <> 'cancelled'
- GROUP BY customer_id
- HAVING min(order_date) in September
- SELECT customer_id, min(order_date)
- ORDER BY customer_id
| customer_id | order_date | status |
|---|---|---|
| 1 | 2026-09-02 | delivered |
| 1 | 2026-10-11 | shipped |
| 4 | 2026-08-20 | delivered |
| 4 | 2026-09-09 | placed |
| 9 | 2026-08-25 | cancelled |
| 9 | 2026-09-10 | placed |
| 6 | 2026-10-04 | cancelled |
| 6 | 2026-10-18 | placed |
Step 1 of 6. FROM orders reads all 8 rows. Nothing else has run yet.
SELECT customer_id, min(order_date) AS first_order FROM orders WHERE status <> 'cancelled' GROUP BY customer_id HAVING min( order_date) >= '2026-09-01' AND min( order_date) < '2026-10-01' ORDER BY customer_id;
The pay list
Section titled “The pay list”WITH staff runs first and leaves out the people on probation, once. Then the company average and each department read staff, so they always count the same people. Type your own staff, like a department of one, or a new hire with a big salary. The lesson: A team of one made the above-average pay list.
- WITH staff AS (…)
- (SELECT avg(salary) FROM staff)
- FROM staff GROUP BY department
- HAVING avg(salary) > the average
- SELECT name, round(avg(salary), 2)
- ORDER BY name
| Kept? | department | salary | hired_on |
|---|---|---|---|
| kept | Engineering | 9500.00 | 2023-02-01 |
| kept | Engineering | 5400.00 | 2025-09-01 |
| kept | Support | 4300.00 | 2025-03-17 |
| dropped | Support | 30000.00 | 2026-10-01 |
| kept | Sales | 6800.00 | 2024-07-29 |
| kept | Sales | 4900.00 | 2026-01-05 |
| kept | Data | 6000.00 | 2024-03-01 |
Step 1 of 6. WITH staff runs first. It keeps the people hired before 2026-05-01, and leaves out the one still on probation. From now on, the query reads staff. Question: Next, the company average reads staff. Will it count the Support hire paid 30000.00, still on probation?
What happens next? Next, the company average reads staff. Will it count the Support hire paid 30000.00, still on probation?
WITH staff AS ( SELECT d.name AS department, e.salary FROM employees e JOIN departments d ON d.department_id = e.department_id WHERE e.hired_on < '2026-05-01' ) SELECT department AS name, round(avg( salary), 2) AS avg_salary FROM staff GROUP BY department HAVING avg( salary) > ( SELECT avg( salary) FROM staff) ORDER BY name;
The growing orders
Section titled “The growing orders”The subquery runs once for each order, with that order’s customer and date. It picks the same customer’s order just before it, by date, and skips cancelled ones. Type your own orders, like one typed in late with an earlier date. The lesson: Bigger than the order before, or just typed in late?.
- FROM orders o
- WHERE o.status <> 'cancelled'
- For each row: o.total > (the subquery)
- ORDER BY o.order_id
| order_id | customer_id | order_date | total | status |
|---|---|---|---|---|
| 101 | 1 | 2026-09-02 | 29.89 | delivered |
| 103 | 1 | 2026-09-20 | 104.00 | delivered |
| 108 | 1 | 2026-10-11 | 36.99 | shipped |
| 115 | 1 | 2026-10-01 | 50.00 | delivered |
| 112 | 7 | 2026-10-16 | 9.99 | delivered |
| 116 | 7 | 2026-10-17 | 5.00 | cancelled |
| 117 | 7 | 2026-10-18 | 9.00 | placed |
Step 1 of 9. FROM orders o reads all 7 rows. The subquery will read orders too, under the name p.
SELECT o.order_id, o.customer_id, o.total FROM orders o WHERE o.status <> 'cancelled' AND o.total > ( SELECT p.total FROM orders p WHERE p.customer_id = o.customer_id AND p.status <> 'cancelled' AND (p.order_date, p.order_id) < ( o.order_date, o.order_id) ORDER BY p.order_date DESC, p.order_id DESC LIMIT 1 ) ORDER BY o.order_id;
The window shoppers
Section titled “The window shoppers”For each customer, EXISTS looks for one page view and stops at the first, so the customer stays one row. NOT EXISTS looks for that customer’s add to cart, so a guest’s NULL never gets in the way. Type your own events, like a guest who adds to cart. The lesson: Grace got the window-shopper email twice.
- FROM customers c
- EXISTS (a page_view)
- NOT EXISTS (an add_to_cart)
- ORDER BY c.customer_id
| customer_id | name |
|---|---|
| 1 | Asha Rao |
| 2 | Ben Okafor |
| 3 | Chen Wei |
| 4 | Dana Cohen |
| 5 | Elif Demir |
| 6 | Farid Haddad |
| 7 | Grace Kim |
| 8 | Hugo Silva |
Step 1 of 8. FROM customers c reads all 8 customers. For each one, the two subqueries look in events.
SELECT c.customer_id, c.name FROM customers c WHERE EXISTS ( SELECT 1 FROM events v WHERE v.customer_id = c.customer_id AND v.event_type = 'page_view' ) AND NOT EXISTS ( SELECT 1 FROM events a WHERE a.customer_id = c.customer_id AND a.event_type = 'add_to_cart' ) ORDER BY c.customer_id;
Window functions
Section titled “Window functions”Number each customer’s orders from newest to oldest with ROW_NUMBER(), then keep number 1: each customer’s latest order. Rows in the same group share a shade. The lesson: Each customer’s latest order.
- FROM orders
- PARTITION BY
- ORDER BY in each group
- ROW_NUMBER
- WHERE rn = 1
- ORDER BY
| customer_id | order_id | order_date | rn |
|---|---|---|---|
| 1 | 101 | 2026-09-02 | |
| 2 | 102 | 2026-09-05 | |
| 1 | 103 | 2026-09-20 | |
| 3 | 104 | 2026-09-21 | |
| 2 | 106 | 2026-10-03 | |
| 1 | 108 | 2026-10-11 | |
| 2 | 109 | 2026-10-14 | |
| 2 | 110 | 2026-10-14 |
Step 1 of 8. The inner query runs first. FROM orders reads every row: 8 orders.
SELECT customer_id, order_id, order_date, total FROM ( SELECT o.*, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY order_date DESC, order_id DESC ) AS rn FROM orders AS o ) AS ranked WHERE rn = 1 ORDER BY customer_id;