A view is a stored SELECT. It holds no data of its own — every time you read it, MySQL runs the query underneath. Views give a complicated query a simple name, keep business rules (revenue = completed orders only) in one place, and can expose only some columns to some users.
14. Views, indexes and EXPLAIN
Step 1 of 4 · Lesson
Lesson 14 of 21
MySQL views, indexes and EXPLAIN: reusable queries and faster lookups
Save a query as a view, add the right index, read EXPLAIN (type, key, rows) on a 4,000-order dataset, and turn data-quality checks into reusable views.
In short
What does EXPLAIN show in MySQL?
EXPLAIN shows how MySQL plans to run a query without running it. Read three columns first: type (ALL = full table scan; ref, range or const = using an index), key (the index chosen, or NULL) and rows (MySQL's estimate of rows to examine). A full scan on a large table usually means a missing or unusable index.
By the end of this lesson you can
- Save a reporting query as a view and query it like a table
- Explain what an index is, when it helps and what it costs
- Create single-column and composite indexes and apply the leftmost-prefix rule
- Read EXPLAIN's type, key and rows columns
- Package data-quality checks as views
Views: save a query under a name
CREATE VIEW completed_order_totals AS
SELECT o.order_id, o.customer_id, o.order_date, SUM(i.quantity * i.unit_price) AS total
FROM orders o JOIN order_items i ON i.order_id = o.order_id
WHERE o.status = 'completed'
GROUP BY o.order_id, o.customer_id, o.order_date;
SELECT * FROM completed_order_totals ORDER BY order_id;| order_id | customer_id | order_date | total |
|---|---|---|---|
| 101 | 1 | 2026-01-05 | 300.00 |
| 102 | 2 | 2026-01-08 | 1300.00 |
| 103 | 1 | 2026-02-03 | 1400.00 |
| 105 | 4 | 2026-02-15 | 550.00 |
| 106 | 2 | 2026-03-02 | 1300.00 |
| 108 | 4 | 2026-03-11 | 1300.00 |
Six rows, one per completed order. Anyone who needs order totals now writes SELECT … FROM completed_order_totals and cannot forget the status filter or the grain.
Build on a view, change a view
Views can be joined, filtered and grouped like tables — and used by other views. CREATE OR REPLACE VIEW changes the definition without dropping it first; DROP VIEW name removes it (the underlying tables are untouched).
CREATE VIEW completed_order_totals AS SELECT o.order_id, o.customer_id, o.order_date, SUM(i.quantity * i.unit_price) AS total FROM orders o JOIN order_items i ON i.order_id = o.order_id WHERE o.status = 'completed' GROUP BY o.order_id, o.customer_id, o.order_date;
SELECT c.customer_name, COUNT(*) AS orders, SUM(t.total) AS revenue
FROM completed_order_totals t
JOIN customers c ON c.customer_id = t.customer_id
GROUP BY c.customer_id, c.customer_name
ORDER BY revenue DESC;| customer_name | orders | revenue |
|---|---|---|
| Bilal | 2 | 2600.00 |
| Dev | 2 | 1850.00 |
| Asha | 2 | 1700.00 |
What an index is
Without help, finding all orders of customer 42 means reading every order — a full table scan. An index is a sorted copy of one or more columns with pointers back to the rows, like the index at the back of a book: jump to “42”, read the page numbers, done.
MySQL (InnoDB) already indexes:
- every PRIMARY KEY (the table itself is stored in primary-key order),
- every UNIQUE column,
- every FOREIGN KEY column — that is the
customer_idindex you saw in lesson 3.
Indexes are not free: each one uses disk space and must be updated on every INSERT, UPDATE and DELETE. Add them for real queries, not just in case.
Reading EXPLAIN
Put EXPLAIN in front of a SELECT to see the plan instead of the result. On the large dataset (4,000 orders), filter orders by date — a column with no index:
EXPLAIN SELECT order_id, order_date FROM orders
WHERE order_date >= '2026-03-01' AND order_date < '2026-04-01';| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | orders | NULL | ALL | NULL | NULL | NULL | NULL | 4000 | 11.11 | Using where |
The columns to read first:
| Column | Meaning | Good signs |
|---|---|---|
| type | how rows are found | const, eq_ref, ref, range use an index; ALL = full scan |
| key | index actually used | a name, not NULL |
| rows | estimated rows examined | close to the number you need |
| Extra | notes | Using index is great; Using filesort / Using temporary cost extra on big results |
Here type is ALL and rows is about 4,000: MySQL reads every order to find one month.
Add an index and compare
Create an index on order_date and run the same EXPLAIN:
CREATE INDEX idx_orders_order_date ON orders (order_date);
EXPLAIN SELECT order_id, order_date FROM orders
WHERE order_date >= '2026-03-01' AND order_date < '2026-04-01';| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | orders | NULL | range | idx_orders_order_date | idx_orders_order_date | 3 | NULL | 221 | 100 | Using where; Using index |
Now type is range, key is idx_orders_order_date, and rows drops to roughly the orders in March. The index even contains both selected columns (order_date plus the primary key), so Extra says Using index: MySQL never touches the table itself.
Composite indexes and the leftmost prefix
An index on several columns, (status, order_date), is sorted by status first, then by date inside each status. It can serve queries that filter on:
statusalone ✔statusandorder_date✔ (best for completed orders in March)order_datealone ✘ — the dates are scattered across the status groups.
That is the leftmost-prefix rule: an index helps from its first column onwards. Put the column you test with = first and the range column last.
CREATE INDEX idx_orders_status_date ON orders (status, order_date);
EXPLAIN SELECT COUNT(*) FROM orders
WHERE status = 'completed'
AND order_date >= '2026-03-01' AND order_date < '2026-04-01';| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | orders | NULL | range | idx_orders_status_date | idx_orders_status_date | 85 | NULL | 190 | 100 | Using where; Using index |
Data-quality checks as views
Professionals run the same checks after every import: orphans, duplicates, impossible values. Save them as one view that returns zero problems when the data is healthy:
CREATE VIEW data_quality AS
SELECT 'orphan items' AS check_name, COUNT(*) AS problems
FROM order_items i LEFT JOIN orders o ON o.order_id = i.order_id WHERE o.order_id IS NULL
UNION ALL
SELECT 'orders without items', COUNT(*)
FROM orders o WHERE NOT EXISTS (SELECT 1 FROM order_items i WHERE i.order_id = o.order_id)
UNION ALL
SELECT 'non-positive prices', COUNT(*) FROM products WHERE price <= 0
UNION ALL
SELECT 'missing customer emails', COUNT(*) FROM customers WHERE email IS NULL;
SELECT * FROM data_quality;| check_name | problems |
|---|---|
| orphan items | 0 |
| orders without items | 0 |
| non-positive prices | 0 |
| missing customer emails | 1 |
Three zeros and one known exception (Charu has no email). Constraints from lesson 12 make the first three impossible; the view proves it and catches anything a future change breaks.
Common mistakes
Wrong
Adding an index to every column
Fix
Index columns used in WHERE, JOIN and ORDER BY of real, frequent queries.
Why: Each index slows every write and uses space; most will never be used.
Wrong
WHERE YEAR(order_date) = 2026Fix
WHERE order_date >= '2026-01-01' AND order_date < '2027-01-01'Why: A function on the column prevents index use (type ALL).
Wrong
Index (order_date, status) for WHERE status = 'completed'
Fix
Index (status, order_date) — equality column first.
Why: Leftmost-prefix: the index is sorted by date first.
Wrong
Thinking a view stores a copy of the data
Fix
Use a real table (refreshed by a job) when you need a stored snapshot.
Why: It re-runs its query every time.
Check yourself
- In EXPLAIN, what do type = ALL and key = NULL tell you together?
- Which queries can use an index on (status, order_date), and which cannot?
- What does a view store?
Answer out loud or in your notebook. If one is hard, reread that section before practising.
Frequently asked questions
Does a MySQL view store data?
No. A view stores only its SELECT statement; the data is read from the base tables each time. MySQL has no materialised views — use a summary table refreshed on a schedule instead.
How do I know if MySQL uses my index?
Run EXPLAIN on the query. The key column names the index used; type ref, range, eq_ref or const means index access, while ALL means a full table scan.
Do foreign keys create indexes in MySQL?
Yes. InnoDB requires an index on foreign-key columns and creates one automatically if none exists.
Quick quiz — 4 questions
Quick Check: Views, indexes and EXPLAIN
Meet Learning Shop
One small fictional shop powers the whole course: 6 customers, 6 products, 8 orders and 14 order lines, small enough to check every answer by eye. The practice editor on this page already contains it. To practise in MySQL Workbench, import it once.
customers.customer_id → orders.customer_id · orders.order_id → order_items.order_id · products.product_id → order_items.product_id
Import in MySQL Workbench
- Connect to your local MySQL server (8.0.16 or newer; 8.4 recommended).
- Choose File → Open SQL Script and open the downloaded file.
- Click the lightning bolt to execute the whole script. The last result shows 6, 6, 8 and 14 rows.
- Refresh the Schemas panel, then select the database:
USE portfolio_retail_lab;
SHOW TABLES;Command line instead: mysql -u root -p < mysql-retail-lab.sql
In the practice editor, the Reset database button does the same as the reset script — instantly and only in your browser.
Let’s do one together
Create a view, then use it to answer two questions without repeating the logic.
- 1Run the script: the view is created and its six rows are listed.
- 2Replace the last SELECT with SELECT ROUND(AVG(total), 2) FROM completed_order_totals; — 1025.00 again.
- 3Switch to the Large dataset and run the script again — the same view now summarises 4,000 orders.
- 4On Large, run EXPLAIN SELECT * FROM orders WHERE customer_id = 42; and find the key column (it uses the foreign-key index).
CREATE VIEW completed_order_totals AS SELECT o.order_id, o.customer_id, o.order_date, SUM(i.quantity * i.unit_price) AS total FROM orders o JOIN order_items i ON i.order_id = o.order_id WHERE o.status = 'completed' GROUP BY o.order_id, o.customer_id, o.order_date;
SELECT * FROM completed_order_totals ORDER BY order_id;What you should see
Six rows: 101 300.00, 102 1300.00, 103 1400.00, 105 550.00, 106 1300.00, 108 1300.00.
| order_id | customer_id | order_date | total |
|---|---|---|---|
| 101 | 1 | 2026-01-05 | 300.00 |
| 102 | 2 | 2026-01-08 | 1300.00 |
| 103 | 1 | 2026-02-03 | 1400.00 |
| 105 | 4 | 2026-02-15 | 550.00 |
| 106 | 2 | 2026-03-02 | 1300.00 |
| 108 | 4 | 2026-03-11 | 1300.00 |
Practice problems
15 problems from Easy to Professional · 0 solved · J / K to move
Create the completed_order_totals view (order_id, customer_id, order_date, total for completed orders) and list it.
Target: 6 rows; totals 300, 1300, 1400, 550, 1300, 1300
Database already selected: Learning Shop (small)(portfolio_retail_lab)— write the query directly, no USE needed.
I’m stuck — show a hint
CREATE VIEW … AS SELECT … GROUP BY …; then SELECT * FROM the view.
Reference answer
CREATE VIEW completed_order_totals AS SELECT o.order_id, o.customer_id, o.order_date, SUM(i.quantity * i.unit_price) AS total FROM orders o JOIN order_items i ON i.order_id = o.order_id WHERE o.status = 'completed' GROUP BY o.order_id, o.customer_id, o.order_date; SELECT * FROM completed_order_totals ORDER BY order_id;| order_id | customer_id | order_date | total |
|---|---|---|---|
| 101 | 1 | 2026-01-05 | 300.00 |
| 102 | 2 | 2026-01-08 | 1300.00 |
| 103 | 1 | 2026-02-03 | 1400.00 |
| 105 | 4 | 2026-02-15 | 550.00 |
| 106 | 2 | 2026-03-02 | 1300.00 |
| 108 | 4 | 2026-03-11 | 1300.00 |
More than one query can be correct. If your result matches the target, your query is right.
Using the view, list completed orders worth more than 1000.
Target: 4 rows: 102, 103, 106, 108
I’m stuck — show a hint
Filter the view like a table.
Reference answer
CREATE VIEW completed_order_totals AS SELECT o.order_id, o.customer_id, o.order_date, SUM(i.quantity * i.unit_price) AS total FROM orders o JOIN order_items i ON i.order_id = o.order_id WHERE o.status = 'completed' GROUP BY o.order_id, o.customer_id, o.order_date; SELECT order_id, total FROM completed_order_totals WHERE total > 1000 ORDER BY order_id;| order_id | total |
|---|---|
| 102 | 1300.00 |
| 103 | 1400.00 |
| 106 | 1300.00 |
| 108 | 1300.00 |
More than one query can be correct. If your result matches the target, your query is right.
Create a second view customer_revenue on top of completed_order_totals (customer_name, orders, revenue) and list it, highest revenue first.
Target: 3 rows: Bilal 2600.00, Dev 1850.00, Asha 1700.00
I’m stuck — show a hint
A view can select from another view.
Reference answer
CREATE VIEW completed_order_totals AS SELECT o.order_id, o.customer_id, o.order_date, SUM(i.quantity * i.unit_price) AS total FROM orders o JOIN order_items i ON i.order_id = o.order_id WHERE o.status = 'completed' GROUP BY o.order_id, o.customer_id, o.order_date; CREATE VIEW customer_revenue AS SELECT c.customer_name, COUNT(*) AS orders, SUM(t.total) AS revenue FROM completed_order_totals t JOIN customers c ON c.customer_id = t.customer_id GROUP BY c.customer_id, c.customer_name; SELECT * FROM customer_revenue ORDER BY revenue DESC;| customer_name | orders | revenue |
|---|---|---|
| Bilal | 2 | 2600.00 |
| Dev | 2 | 1850.00 |
| Asha | 2 | 1700.00 |
More than one query can be correct. If your result matches the target, your query is right.
Create a view of Pune customers, then replace it with CREATE OR REPLACE VIEW so it shows Mumbai customers, and query it.
Target: 2 rows: Bilal, Esha
I’m stuck — show a hint
CREATE OR REPLACE VIEW keeps the name and swaps the query.
Reference answer
CREATE VIEW city_customers AS SELECT customer_id, customer_name FROM customers WHERE city = 'Pune'; CREATE OR REPLACE VIEW city_customers AS SELECT customer_id, customer_name FROM customers WHERE city = 'Mumbai'; SELECT * FROM city_customers ORDER BY customer_id;| customer_id | customer_name |
|---|---|
| 2 | Bilal |
| 5 | Esha |
More than one query can be correct. If your result matches the target, your query is right.
Create an index on orders(order_date) and list the index names on orders.
Target: PRIMARY, customer_id and idx_orders_order_date
I’m stuck — show a hint
CREATE INDEX …; SHOW INDEX FROM orders;
Reference answer
CREATE INDEX idx_orders_order_date ON orders (order_date); SHOW INDEX FROM orders;| Table | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| orders | 0 | PRIMARY | 1 | order_id | A | 1 | NULL | NULL | BTREE | YES | NULL | |||
| orders | 1 | customer_id | 1 | customer_id | A | 1 | NULL | NULL | BTREE | YES | NULL | |||
| orders | 1 | idx_orders_order_date | 1 | order_date | A | 8 | NULL | NULL | BTREE | YES | NULL |
More than one query can be correct. If your result matches the target, your query is right.
Create a view unsold_products listing products that never appear in a completed order, and query it.
Target: 1 row: Planner
I’m stuck — show a hint
NOT EXISTS with a join to orders filtered on status.
Reference answer
CREATE VIEW unsold_products AS SELECT p.product_id, p.product_name FROM products p WHERE NOT EXISTS (SELECT 1 FROM order_items i JOIN orders o ON o.order_id = i.order_id WHERE i.product_id = p.product_id AND o.status = 'completed'); SELECT * FROM unsold_products;| product_id | product_name |
|---|---|
| 6 | Planner |
More than one query can be correct. If your result matches the target, your query is right.
Large dataset: count customer 42's orders by status.
Target: Up to 3 rows (completed / cancelled / pending) whose counts add up to customer 42's order count
I’m stuck — show a hint
WHERE customer_id = 42 GROUP BY status — this lookup uses the foreign-key index.
Reference answer
SELECT status, COUNT(*) AS orders FROM orders WHERE customer_id = 42 GROUP BY status ORDER BY status;| status | orders |
|---|---|
| cancelled | 2 |
| completed | 9 |
| pending | 2 |
More than one query can be correct. If your result matches the target, your query is right.
Large dataset: add a composite index for status + order_date, then count completed orders in March 2026.
Target: 1 row: the number of completed March 2026 orders (same answer with or without the index)
I’m stuck — show a hint
CREATE INDEX … ON orders (status, order_date); then a half-open date range.
Reference answer
CREATE INDEX idx_orders_status_date ON orders (status, order_date); SELECT COUNT(*) AS completed_march FROM orders WHERE status = 'completed' AND order_date >= '2026-03-01' AND order_date < '2026-04-01';| completed_march |
|---|
| 190 |
More than one query can be correct. If your result matches the target, your query is right.
Create a monthly_revenue view (month, revenue for completed orders) and query it with a running total.
Target: 3 rows: 2026-01 1600 / 1600, 2026-02 1950 / 3550, 2026-03 2600 / 6150
I’m stuck — show a hint
View for the grouping; window SUM in the outer query.
Reference answer
CREATE VIEW monthly_revenue AS SELECT DATE_FORMAT(o.order_date, '%Y-%m') AS month, SUM(i.quantity * i.unit_price) AS revenue FROM orders o JOIN order_items i ON i.order_id = o.order_id WHERE o.status = 'completed' GROUP BY month; SELECT month, revenue, SUM(revenue) OVER (ORDER BY month ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total FROM monthly_revenue ORDER BY month;| month | revenue | running_total |
|---|---|---|
| 2026-01 | 1600.00 | 1600.00 |
| 2026-02 | 1950.00 | 3550.00 |
| 2026-03 | 2600.00 | 6150.00 |
More than one query can be correct. If your result matches the target, your query is right.
Audit for orphan order items and inspect a date-filter plan.
Target: Zero orphan rows. Record EXPLAIN's key, rows and type; no timing/speedup claim.
I’m stuck — show a hint
LEFT JOIN orders for orphans; EXPLAIN a bounded date query. Tiny data may favour a full scan.
Reference answer
SELECT COUNT(*) AS orphans FROM order_items i LEFT JOIN orders o ON o.order_id=i.order_id WHERE o.order_id IS NULL;
EXPLAIN SELECT order_id FROM orders WHERE order_date >= '2026-02-01' AND order_date < '2026-03-01';
-- Candidate for a larger workload: CREATE INDEX idx_orders_date ON orders(order_date);
-- Inspect SHOW INDEX FROM orders before adding an index; DDL is not rolled back.| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | orders | NULL | ALL | NULL | NULL | NULL | NULL | 8 | 12.5 | Using where |
More than one query can be correct. If your result matches the target, your query is right.
Data-quality check: find duplicate customer emails (excluding NULL).
Target: 0 rows — UNIQUE prevents duplicates
I’m stuck — show a hint
GROUP BY email HAVING COUNT(*) > 1.
Reference answer
SELECT email, COUNT(*) AS n FROM customers WHERE email IS NOT NULL GROUP BY email HAVING COUNT(*) > 1;| n | |
|---|---|
| Empty set (0 rows) | |
More than one query can be correct. If your result matches the target, your query is right.
Data-quality check: find order lines whose quantity or unit_price is zero or negative.
Target: 0 rows
I’m stuck — show a hint
WHERE quantity <= 0 OR unit_price <= 0.
Reference answer
SELECT * FROM order_items WHERE quantity <= 0 OR unit_price <= 0;| order_id | product_id | quantity | unit_price |
|---|---|---|---|
| Empty set (0 rows) | |||
More than one query can be correct. If your result matches the target, your query is right.
Data-quality check: find orders with no items at all.
Target: 0 rows
I’m stuck — show a hint
LEFT JOIN orders to order_items, test NULL.
Reference answer
SELECT o.order_id FROM orders o LEFT JOIN order_items i ON i.order_id = o.order_id WHERE i.order_id IS NULL;| order_id |
|---|
| Empty set (0 rows) |
More than one query can be correct. If your result matches the target, your query is right.
Run EXPLAIN on a lookup by customer_id and record type, key and rows.
Target: Usually type ref on the customer_id index; a tiny table may show ALL. No speed claim.
I’m stuck — show a hint
EXPLAIN SELECT ... WHERE customer_id = 1.
Reference answer
EXPLAIN SELECT * FROM orders WHERE customer_id = 1;| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | orders | NULL | ref | customer_id | customer_id | 4 | const | 2 | 100 | NULL |
More than one query can be correct. If your result matches the target, your query is right.
One-result data-quality report: orphan items, orders without items, negative prices, duplicate emails — each with its count.
Target: 4 rows, every count 0
I’m stuck — show a hint
UNION ALL four COUNT queries with a check_name column.
Reference answer
SELECT 'orphan_items' AS check_name, COUNT(*) AS problems FROM order_items i LEFT JOIN orders o ON o.order_id = i.order_id WHERE o.order_id IS NULL UNION ALL SELECT 'orders_without_items', COUNT(*) FROM orders o LEFT JOIN order_items i ON i.order_id = o.order_id WHERE i.order_id IS NULL UNION ALL SELECT 'negative_prices', COUNT(*) FROM products WHERE price < 0 UNION ALL SELECT 'duplicate_emails', COUNT(*) FROM (SELECT email FROM customers WHERE email IS NOT NULL GROUP BY email HAVING COUNT(*) > 1) d;| check_name | problems |
|---|---|
| orphan_items | 0 |
| orders_without_items | 0 |
| negative_prices | 0 |
| duplicate_emails | 0 |
More than one query can be correct. If your result matches the target, your query is right.
Practice mode runs in your browser on a SQLite engine with a MySQL compatibility layer, so you can practise without installing anything. Your result is compared with the reference answer on a fresh copy of the same rows, so any correct query is accepted. A few MySQL-only features (EXPLAIN columns, WITH ROLLUP, user variables) behave differently here — run those in MySQL Workbench.
Finished? Save your queries in a .sql file and mark the lesson complete.
Answers — Views, indexes and EXPLAIN
Easy. Create the completed_order_totals view (order_id, customer_id, order_date, total for completed orders) and list it. Target: 6 rows; totals 300, 1300, 1400, 550, 1300, 1300
CREATE VIEW completed_order_totals AS SELECT o.order_id, o.customer_id, o.order_date, SUM(i.quantity * i.unit_price) AS total FROM orders o JOIN order_items i ON i.order_id = o.order_id WHERE o.status = 'completed' GROUP BY o.order_id, o.customer_id, o.order_date; SELECT * FROM completed_order_totals ORDER BY order_id;
Easy. Using the view, list completed orders worth more than 1000. Target: 4 rows: 102, 103, 106, 108
CREATE VIEW completed_order_totals AS SELECT o.order_id, o.customer_id, o.order_date, SUM(i.quantity * i.unit_price) AS total FROM orders o JOIN order_items i ON i.order_id = o.order_id WHERE o.status = 'completed' GROUP BY o.order_id, o.customer_id, o.order_date; SELECT order_id, total FROM completed_order_totals WHERE total > 1000 ORDER BY order_id;
Medium. Create a second view customer_revenue on top of completed_order_totals (customer_name, orders, revenue) and list it, highest revenue first. Target: 3 rows: Bilal 2600.00, Dev 1850.00, Asha 1700.00
CREATE VIEW completed_order_totals AS SELECT o.order_id, o.customer_id, o.order_date, SUM(i.quantity * i.unit_price) AS total FROM orders o JOIN order_items i ON i.order_id = o.order_id WHERE o.status = 'completed' GROUP BY o.order_id, o.customer_id, o.order_date; CREATE VIEW customer_revenue AS SELECT c.customer_name, COUNT(*) AS orders, SUM(t.total) AS revenue FROM completed_order_totals t JOIN customers c ON c.customer_id = t.customer_id GROUP BY c.customer_id, c.customer_name; SELECT * FROM customer_revenue ORDER BY revenue DESC;
Medium. Create a view of Pune customers, then replace it with CREATE OR REPLACE VIEW so it shows Mumbai customers, and query it. Target: 2 rows: Bilal, Esha
CREATE VIEW city_customers AS SELECT customer_id, customer_name FROM customers WHERE city = 'Pune'; CREATE OR REPLACE VIEW city_customers AS SELECT customer_id, customer_name FROM customers WHERE city = 'Mumbai'; SELECT * FROM city_customers ORDER BY customer_id;
Medium. Create an index on orders(order_date) and list the index names on orders. Target: PRIMARY, customer_id and idx_orders_order_date
CREATE INDEX idx_orders_order_date ON orders (order_date); SHOW INDEX FROM orders;
Hard. Create a view unsold_products listing products that never appear in a completed order, and query it. Target: 1 row: Planner
CREATE VIEW unsold_products AS SELECT p.product_id, p.product_name FROM products p WHERE NOT EXISTS (SELECT 1 FROM order_items i JOIN orders o ON o.order_id = i.order_id WHERE i.product_id = p.product_id AND o.status = 'completed'); SELECT * FROM unsold_products;
Hard. Large dataset: count customer 42's orders by status. Target: Up to 3 rows (completed / cancelled / pending) whose counts add up to customer 42's order count
SELECT status, COUNT(*) AS orders FROM orders WHERE customer_id = 42 GROUP BY status ORDER BY status;
Hard. Large dataset: add a composite index for status + order_date, then count completed orders in March 2026. Target: 1 row: the number of completed March 2026 orders (same answer with or without the index)
CREATE INDEX idx_orders_status_date ON orders (status, order_date); SELECT COUNT(*) AS completed_march FROM orders WHERE status = 'completed' AND order_date >= '2026-03-01' AND order_date < '2026-04-01';
Professional. Create a monthly_revenue view (month, revenue for completed orders) and query it with a running total. Target: 3 rows: 2026-01 1600 / 1600, 2026-02 1950 / 3550, 2026-03 2600 / 6150
CREATE VIEW monthly_revenue AS SELECT DATE_FORMAT(o.order_date, '%Y-%m') AS month, SUM(i.quantity * i.unit_price) AS revenue FROM orders o JOIN order_items i ON i.order_id = o.order_id WHERE o.status = 'completed' GROUP BY month; SELECT month, revenue, SUM(revenue) OVER (ORDER BY month ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total FROM monthly_revenue ORDER BY month;
Hard. Audit for orphan order items and inspect a date-filter plan. Target: Zero orphan rows. Record EXPLAIN's key, rows and type; no timing/speedup claim.
SELECT COUNT(*) AS orphans FROM order_items i LEFT JOIN orders o ON o.order_id=i.order_id WHERE o.order_id IS NULL; EXPLAIN SELECT order_id FROM orders WHERE order_date >= '2026-02-01' AND order_date < '2026-03-01'; -- Candidate for a larger workload: CREATE INDEX idx_orders_date ON orders(order_date); -- Inspect SHOW INDEX FROM orders before adding an index; DDL is not rolled back.
Hard. Data-quality check: find duplicate customer emails (excluding NULL). Target: 0 rows — UNIQUE prevents duplicates
SELECT email, COUNT(*) AS n FROM customers WHERE email IS NOT NULL GROUP BY email HAVING COUNT(*) > 1;
Hard. Data-quality check: find order lines whose quantity or unit_price is zero or negative. Target: 0 rows
SELECT * FROM order_items WHERE quantity <= 0 OR unit_price <= 0;
Hard. Data-quality check: find orders with no items at all. Target: 0 rows
SELECT o.order_id FROM orders o LEFT JOIN order_items i ON i.order_id = o.order_id WHERE i.order_id IS NULL;
Hard. Run EXPLAIN on a lookup by customer_id and record type, key and rows. Target: Usually type ref on the customer_id index; a tiny table may show ALL. No speed claim.
EXPLAIN SELECT * FROM orders WHERE customer_id = 1;
Professional. One-result data-quality report: orphan items, orders without items, negative prices, duplicate emails — each with its count. Target: 4 rows, every count 0
SELECT 'orphan_items' AS check_name, COUNT(*) AS problems FROM order_items i LEFT JOIN orders o ON o.order_id = i.order_id WHERE o.order_id IS NULL UNION ALL SELECT 'orders_without_items', COUNT(*) FROM orders o LEFT JOIN order_items i ON i.order_id = o.order_id WHERE i.order_id IS NULL UNION ALL SELECT 'negative_prices', COUNT(*) FROM products WHERE price < 0 UNION ALL SELECT 'duplicate_emails', COUNT(*) FROM (SELECT email FROM customers WHERE email IS NOT NULL GROUP BY email HAVING COUNT(*) > 1) d;
Up next · Lesson 15
Capstone: audited retail report
180 min · 15 practice problems