Skip to content

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.

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 steps
The 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)
Runs in this browser. Loads about 5 MB the first time, best on Wi-Fi.

Under every SQL editor in the lessons, Show the steps does the same.

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.

Rows checked against the keys
  1. PRIMARY KEY
  2. UNIQUE (customer_id, product_id)
  3. REFERENCES products
  4. REFERENCES customers
products
product_idname
1Notebook
2Gel pen (5 pack)
3Desk lamp
4Water bottle
5Wireless mouse
6USB-C cable
7Headphones
8Backpack
customers
customer_idname
1Asha Rao
2Ben Okafor
3Chen Wei
4Dana Cohen
5Elif Demir
6Farid Haddad
7Grace Kim
8Hugo Silva
reviews
review_idproduct_idcustomer_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)
);

Try your own input

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.

The warehouse list, step by step
  1. FROM orders
  2. WHERE status = 'placed'
  3. SELECT order_id, order_date
  4. ORDER BY order_date, order_id
  5. LIMIT 3
orders
order_idorder_datestatus
1012026-10-02placed
1042026-10-05shipped
1032026-10-04placed
1022026-10-04placed
1052026-10-01placed

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;

Try your own input

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.

The second-highest price, step by step
  1. FROM products
  2. SELECT price
  3. DISTINCT
  4. ORDER BY price DESC
  5. OFFSET 1
  6. LIMIT 1
  7. SELECT (…) AS second_price
products
nameprice
Desk lamp24.99
Water bottle12.00
Lamp shade24.99
Notebook4.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;

Try your own input

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.

The sales tile, step by step
  1. FROM orders
  2. WHERE order_date = '2026-10-20'
  3. count(*)
  4. count(DISTINCT customer_id)
  5. coalesce(sum(total), 0)
  6. round(avg(total), 2)
orders
customer_idorder_datetotal
32026-10-2012.50
32026-10-2030.00
52026-10-207.25
42026-10-1918.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';

Try your own input

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.

The loyalty coupon, step by step
  1. FROM orders
  2. WHERE status <> 'cancelled'
  3. GROUP BY customer_id
  4. HAVING count(DISTINCT order_date) >= 3
  5. SELECT customer_id
  6. ORDER BY customer_id
orders
customer_idorder_datestatus
32026-10-01delivered
32026-10-08shipped
32026-10-08placed
32026-10-15placed
42026-10-02cancelled
42026-10-09placed
42026-10-12placed
52026-10-20placed
52026-10-20placed
52026-10-20placed

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;

Try your own input

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.

The reminder list, step by step
  1. FROM events
  2. WHERE event_type = 'checkout'
  3. AND customer_id IS NOT NULL
  4. FROM customers
  5. WHERE customer_id NOT IN (…)
  6. SELECT customer_id, name
  7. ORDER BY customer_id
checkouts
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;

Try your own input

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.

The payments report, step by step
  1. FROM orders
  2. LEFT JOIN payments
  3. WHERE status <> 'cancelled'
  4. GROUP BY order_id, total
  5. SELECT …, coalesce(sum(amount), 0)
  6. ORDER BY order_id
payments
order_idamount
1129.99
11320.00
11329.98
114142.99
orders
order_idstatustotal
107cancelled136.00
112delivered9.99
113placed49.98
114placed142.99
115placed24.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;

Try your own input

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.

The double-order check, step by step
  1. FROM orders a
  2. JOIN orders b ON …
  3. WHERE a.order_id < b.order_id
  4. AND a.status <> 'cancelled'
  5. AND b.status <> 'cancelled'
  6. SELECT
  7. ORDER BY
orders a
order_idcustomer_idstatus
1155placed
1165placed
1175placed
1188cancelled
1198placed

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;

Try your own input

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.

The bottle offer, step by step
  1. First subquery: backpacks
  2. Second subquery: water bottles
  3. FROM customers
  4. WHERE customer_id IN (…)
  5. AND customer_id NOT IN (…)
  6. SELECT customer_id, name
  7. ORDER BY customer_id
orders o JOIN order_items i
Kept?customer_idproduct_idstatus
kept28placed
kept28shipped
dropped44cancelled
kept48placed
dropped68cancelled
dropped64delivered

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;

Try your own input

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.

The duplicate cleanup, step by step
  1. Subquery: FROM payments
  2. GROUP BY order_id, paid_on, method, amount
  3. SELECT min(payment_id)
  4. DELETE … WHERE payment_id NOT IN (…)
  5. The table after
payments
payment_idorder_idmethodamount
14118card24.99
15118card24.99
16118card24.99
17119upi9.99
18119wallet5.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
);

Try your own input

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.

The welcome list, step by step
  1. FROM orders
  2. WHERE status <> 'cancelled'
  3. GROUP BY customer_id
  4. HAVING min(order_date) in September
  5. SELECT customer_id, min(order_date)
  6. ORDER BY customer_id
orders
customer_idorder_datestatus
12026-09-02delivered
12026-10-11shipped
42026-08-20delivered
42026-09-09placed
92026-08-25cancelled
92026-09-10placed
62026-10-04cancelled
62026-10-18placed

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;

Try your own input

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.

The pay list, step by step
  1. WITH staff AS (…)
  2. (SELECT avg(salary) FROM staff)
  3. FROM staff GROUP BY department
  4. HAVING avg(salary) > the average
  5. SELECT name, round(avg(salary), 2)
  6. ORDER BY name
employees
Kept?departmentsalaryhired_on
keptEngineering9500.002023-02-01
keptEngineering5400.002025-09-01
keptSupport4300.002025-03-17
droppedSupport30000.002026-10-01
keptSales6800.002024-07-29
keptSales4900.002026-01-05
keptData6000.002024-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;

Try your own input

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?.

The growing orders, step by step
  1. FROM orders o
  2. WHERE o.status <> 'cancelled'
  3. For each row: o.total > (the subquery)
  4. ORDER BY o.order_id
orders o
order_idcustomer_idorder_datetotalstatus
10112026-09-0229.89delivered
10312026-09-20104.00delivered
10812026-10-1136.99shipped
11512026-10-0150.00delivered
11272026-10-169.99delivered
11672026-10-175.00cancelled
11772026-10-189.00placed

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;

Try your own input

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.

The window shoppers, step by step
  1. FROM customers c
  2. EXISTS (a page_view)
  3. NOT EXISTS (an add_to_cart)
  4. ORDER BY c.customer_id
customers c
customer_idname
1Asha Rao
2Ben Okafor
3Chen Wei
4Dana Cohen
5Elif Demir
6Farid Haddad
7Grace Kim
8Hugo 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;

Try your own input

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.

Number each customer's orders, keep the first
  1. FROM orders
  2. PARTITION BY
  3. ORDER BY in each group
  4. ROW_NUMBER
  5. WHERE rn = 1
  6. ORDER BY
orders
customer_idorder_idorder_datern
11012026-09-02
21022026-09-05
11032026-09-20
31042026-09-21
21062026-10-03
11082026-10-11
21092026-10-14
21102026-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;

Try your own input