#Views
AddedA view is a named SELECT stored in the database. It looks like a table — you can SELECT from it, join it, grant access to it — but it holds no rows of its own. Every time you query it, MySQL runs the stored query against the real tables underneath, so the view is always up to date.
The mental model is a window onto the tables. The window has a fixed frame (the columns and rows the query picks), and looking through it shows whatever is in the room right now. Move the furniture (change the base tables) and the view shows the new arrangement the next time you look. That's different from a photograph (a copy in a summary table), which is fast to look at but goes stale.
Views solve three everyday problems: they give a name to a complicated query so nobody has to rewrite the four-way join again, they provide a stable interface while the tables underneath change, and they're a security tool: you can grant someone access to a view without granting access to the tables it reads (see Users, roles and GRANT).
CREATE VIEW order_totals AS
SELECT o.id AS order_id,
c.name AS customer,
o.status,
SUM(oi.quantity * oi.unit_price) AS total
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id, c.name, o.status;
SELECT * FROM order_totals WHERE status <> 'cancelled' ORDER BY total DESC;
SELECT TABLE_NAME, TABLE_TYPE FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'ch11' AND TABLE_NAME LIKE 'order%';+----------+----------+---------+---------+
| order_id | customer | status | total |
+----------+----------+---------+---------+
| 3 | Maya | shipped | 1048.00 |
| 7 | Noah | paid | 1016.00 |
| 1 | Rohit | shipped | 957.00 |
| 6 | Sofia | pending | 249.00 |
| 5 | Liam | shipped | 127.00 |
| 8 | Maya | shipped | 116.00 |
| 2 | Rohit | paid | 88.00 |
+----------+----------+---------+---------+
+--------------+------------+
| TABLE_NAME | TABLE_TYPE |
+--------------+------------+
| order_items | BASE TABLE |
| order_totals | VIEW |
| orders | BASE TABLE |
+--------------+------------+CREATE VIEW order_totals AS SELECT …stores the query text and its column list in the data dictionary. No rows are computed or copied at this point.SELECT * FROM order_totals WHERE status <> 'cancelled'makes MySQL run the stored join andGROUP BYnow, against the current contents oforders,customersandorder_items, then apply the outerWHEREandORDER BY.- Each order's total is
SUM(quantity × unit_price): order 1 is1 × 899 + 2 × 29 = 957, order 3 is799 + 249 = 1048. Order 4 (Liam's cancelled MacBook) is filtered out by the outerWHERE. - The outer query never mentions a join. Whoever writes reports can treat
order_totalslike a table and doesn't need to know how totals are computed. information_schema.TABLESlists the view next to the real tables withTABLE_TYPE = VIEW. That's why tools and ORMs often can't tell them apart until they try to write to one.
Because a view is re-run on every query, a change to the base table shows up immediately:
UPDATE order_items SET quantity = 2 WHERE order_id = 2 AND product_id = 5;
SELECT * FROM order_totals WHERE order_id = 2;
UPDATE order_items SET quantity = 1 WHERE order_id = 2 AND product_id = 5;+----------+----------+--------+--------+
| order_id | customer | status | total |
+----------+----------+--------+--------+
| 2 | Rohit | paid | 127.00 |
+----------+----------+--------+--------+- Order 2 was one SQL Performance Explained (39) plus one Designing Data-Intensive Applications (49) = 88.
- The
UPDATEchanges the first book's quantity to 2 in the base table only. Nothing touches the view. - Selecting from the view re-runs its query, so the total is now
2 × 39 + 49 = 127. There's no refresh step and no way for the view to be stale. - The last
UPDATEputs the quantity back so later examples use the original data.
MySQL has two ways to execute a view. With the MERGE algorithm it pastes the view's definition into your query and optimises the combined statement, so indexes on the base tables work as if you'd written the join yourself. If the view contains something that can't be merged — GROUP BY, DISTINCT, aggregates, UNION, LIMIT — it uses TEMPTABLE: it materialises the view's result into an internal temporary table (a derived table) and runs your query against that. ALGORITHM = UNDEFINED (the default) lets MySQL pick MERGE when it can.
EXPLAIN FORMAT=TRADITIONAL SELECT * FROM order_totals WHERE customer = 'Maya';
EXPLAIN FORMAT=TRADITIONAL SELECT name FROM toronto_customers WHERE id = 1;+----+-------------+------------+------------+------+---------------------+-------------+---------+-----------+------+----------+------------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+------------+------------+------+---------------------+-------------+---------+-----------+------+----------+------------------------------+
| 1 | PRIMARY | <derived2> | NULL | ALL | NULL | NULL | NULL | NULL | 2 | 100.00 | NULL |
| 2 | DERIVED | c | NULL | ALL | PRIMARY | NULL | NULL | NULL | 6 | 16.67 | Using where; Using temporary |
| 2 | DERIVED | o | NULL | ref | PRIMARY,customer_id | customer_id | 4 | ch11.c.id | 1 | 100.00 | NULL |
| 2 | DERIVED | oi | NULL | ref | PRIMARY | PRIMARY | 4 | ch11.o.id | 1 | 100.00 | NULL |
+----+-------------+------------+------------+------+---------------------+-------------+---------+-----------+------+----------+------------------------------+
+----+-------------+-----------+------------+-------+---------------+---------+---------+-------+------+----------+-------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-----------+------------+-------+---------------+---------+---------+-------+------+----------+-------+
| 1 | SIMPLE | customers | NULL | const | PRIMARY | PRIMARY | 4 | const | 1 | 100.00 | NULL |
+----+-------------+-----------+------------+-------+---------------+---------+---------+-------+------+----------+-------+FORMAT=TRADITIONALasks for the classic table. MySQL 9.7's defaultexplain_formatisTREE, so a plainEXPLAINprints the tree form instead.order_totalshas aGROUP BY, so it can't be merged. Rowid 2is the view's own query, labelledDERIVED; rowid 1reads its result from<derived2>, the temporary table. That's TEMPTABLE.- Look at
c:Using wherewithfiltered 16.67. The optimizer pushed the outer conditioncustomer = 'Maya'down into the derived query (derived-condition pushdown, MySQL 8.0.22+), so only Maya's orders are grouped instead of all of them. Older versions computed every total first, then filtered. toronto_customers(defined in the next example) is a plain filter over one table, so MySQL merged it: the plan is just aconstprimary-key lookup oncustomersand the view disappears. A merged view costs nothing extra.- The practical rule: views with aggregates are fine for reports, but filtering a big aggregated view on a column that can't be pushed down means computing the whole aggregate first. Check the plan.
A view over a single table with no aggregates, DISTINCT or GROUP BY is updatable: an UPDATE or INSERT through it changes the base table. WITH CHECK OPTION adds a guard: a write through the view must produce a row the view can still see.
CREATE VIEW toronto_customers AS
SELECT id, name, email, city, created_at FROM customers WHERE city = 'Toronto'
WITH CHECK OPTION;
UPDATE toronto_customers SET name = 'Noah K.' WHERE id = 5;
SELECT id, name, city FROM customers WHERE id = 5;
UPDATE toronto_customers SET city = 'Ottawa' WHERE id = 5;
INSERT INTO toronto_customers (name, email, city, created_at)
VALUES ('Ava', 'ava@example.com', 'Paris', NOW());
UPDATE toronto_customers SET name = 'Maya' WHERE id = 2;
SELECT ROW_COUNT();
UPDATE order_totals SET total = 0 WHERE order_id = 1;
UPDATE customers SET name = 'Noah' WHERE id = 5;
SELECT TABLE_NAME, IS_UPDATABLE, CHECK_OPTION, SECURITY_TYPE
FROM information_schema.VIEWS WHERE TABLE_SCHEMA = 'ch11';+----+---------+---------+
| id | name | city |
+----+---------+---------+
| 5 | Noah K. | Toronto |
+----+---------+---------+
ERROR 1369 (HY000): CHECK OPTION failed 'ch11.toronto_customers'
ERROR 1369 (HY000): CHECK OPTION failed 'ch11.toronto_customers'
+-------------+
| ROW_COUNT() |
+-------------+
| 0 |
+-------------+
ERROR 1288 (HY000): The target table order_totals of the UPDATE is not updatable
+-------------------+--------------+--------------+---------------+
| TABLE_NAME | IS_UPDATABLE | CHECK_OPTION | SECURITY_TYPE |
+-------------------+--------------+--------------+---------------+
| order_totals | NO | NONE | DEFINER |
| toronto_customers | YES | CASCADED | DEFINER |
+-------------------+--------------+--------------+---------------+- Renaming Noah through the view works: Noah lives in Toronto, the view can see him, and the change lands in
customers. - Moving him to Ottawa would make the row vanish from the view, so
WITH CHECK OPTIONrejects it withERROR 1369. Without the option the update would succeed and the row would silently leave the view — a classic way for a "Toronto admin" screen to edit customers it shouldn't own. - Inserting a Paris customer through a Toronto view fails the same way. (A first attempt with a view that left out
created_atfailed earlier withERROR 1423 … underlying table doesn't have a default value: an insert through a view can only fill the columns the view exposes.) - Updating Maya (Mumbai) through the view isn't an error, it just matches nothing:
ROW_COUNT()is0, because the view'sWHERE city = 'Toronto'is added to the update. order_totalshasGROUP BYandSUM, so there's no single base row behind a total:ERROR 1288 … is not updatable.information_schema.VIEWSreports both facts up front (IS_UPDATABLE,CHECK_OPTION = CASCADED, the default, which also enforces the conditions of any views this one is built on).
A materialized view stores the query's result and refreshes it on demand — the "photograph" from the mental model. PostgreSQL has them; MySQL doesn't. Here's PostgreSQL 18 in the sandbox:
CREATE TABLE orders (id int PRIMARY KEY, customer text, total numeric(10,2));
INSERT INTO orders VALUES (1, 'Rohit', 957.00), (2, 'Rohit', 88.00), (3, 'Maya', 1048.00);
CREATE MATERIALIZED VIEW revenue_by_customer AS
SELECT customer, SUM(total) AS revenue FROM orders GROUP BY customer;
INSERT INTO orders VALUES (4, 'Maya', 116.00);
SELECT * FROM revenue_by_customer ORDER BY customer;
REFRESH MATERIALIZED VIEW revenue_by_customer;
SELECT * FROM revenue_by_customer ORDER BY customer; customer | revenue
----------+---------
Maya | 1048.00
Rohit | 1045.00
(2 rows)
customer | revenue
----------+---------
Maya | 1164.00
Rohit | 1045.00
(2 rows)CREATE MATERIALIZED VIEWruns the query once and stores its rows, like a table.- Maya's new 116 order is inserted, but the first
SELECTstill shows1048.00: the stored rows are a snapshot from creation time. REFRESH MATERIALIZED VIEWre-runs the query and replaces the rows, and Maya becomes1164.00.- In MySQL you build the same thing by hand: a real summary table plus a job that rebuilds it — see the
daily_revenueexample in Scheduled events — or triggers that keep it current on every write.
CREATE TABLE notes (id INT PRIMARY KEY, body VARCHAR(50));
INSERT INTO notes VALUES (1, 'hello');
CREATE VIEW all_notes AS SELECT * FROM notes;
ALTER TABLE notes ADD COLUMN author VARCHAR(20) DEFAULT 'rohit';
SELECT * FROM all_notes;Does the result include author? What happens if you then drop body from the table?
Show answer
No author column, and after dropping body the view is broken.
+----+-------+
| id | body |
+----+-------+
| 1 | hello |
+----+-------+
+----------------------------------------------------------------------------------------+
| VIEW_DEFINITION |
+----------------------------------------------------------------------------------------+
| select `ch11`.`notes`.`id` AS `id`,`ch11`.`notes`.`body` AS `body` from `ch11`.`notes` |
+----------------------------------------------------------------------------------------+
ERROR 1356 (HY000): View 'ch11.all_notes' references invalid table(s) or column(s) or function(s) or definer/invoker of view lack rights to use them- When
CREATE VIEWruns, MySQL expands*into the columns that exist at that moment and stores the expanded list.information_schema.VIEWS.VIEW_DEFINITIONshows it:idandbody, spelled out. ALTER TABLE … ADD COLUMN authorchanges the table, not the stored view text, soSELECT * FROM all_notesstill returns onlyidandbody.ALTER TABLE notes DROP COLUMN bodysucceeds — MySQL doesn't check which views use a column — and the next query on the view fails withERROR 1356. The break shows up at query time, often in production.- The lesson: list columns explicitly in views, recreate views (
CREATE OR REPLACE VIEW) in the same migration that changes their tables, and searchinformation_schema.VIEWSbefore dropping a column.