Lesson 17 of 20

Views

A View Is a Saved Query, Not a Saved Table

A view is a named SELECT statement that the database stores and lets you query as though it were a table. That is the whole idea. What the database stores is the text of the query, not its results — so every time you select from a view, the underlying query runs again, against whatever the base tables contain at that moment.

This is the single most important thing to hold on to, because the word 'virtual table' encourages exactly the wrong mental model. A view is not a copy of your data. It has no rows of its own, it takes almost no disk space, and it is never out of date, because there is nothing to keep up to date. If somebody inserts a row into orders, the next query against a view over orders sees it immediately.

So why bother? Three reasons that matter in real projects. A view lets you write a genuinely complicated join once and then use its name everywhere, so the complexity lives in one place instead of being copy-pasted into fifteen queries with three slightly different versions of the logic. It gives you a stable name to hand to other people, so you can restructure the tables underneath and rewrite only the view. And it lets you expose part of a table — some columns, some rows — to somebody you do not want reading the whole thing.

The mental model to carry through the rest of this lesson: a view is a macro, not a cache. Everything that follows, including every gotcha, falls out of that one sentence.

Example
-- The simplest useful view: one table, one filter
CREATE VIEW active_students AS
SELECT id, name, email, branch, joined_on
FROM students
WHERE is_active = TRUE;

-- Query it exactly as you would query a table
SELECT * FROM active_students;
SELECT name FROM active_students WHERE branch = 'CSE';

-- Nothing was copied. Insert into the table...
INSERT INTO students (name, email, branch, is_active, joined_on)
VALUES ('Ananya Sharma', 'ananya@example.com', 'CSE', TRUE, '2026-07-01');

-- ...and the view already knows about it
SELECT COUNT(*) FROM active_students;
Notes
  • The word 'virtual table' appears in almost every definition of a view and it misleads almost everybody. Read it as 'stored query'. A view that takes eight seconds to run takes eight seconds every single time you select from it — giving a slow query a short name does not make it fast.

Writing a View That Earns Its Keep

The views worth creating are the ones that hide real complexity. A summary of each student's ordering history needs a join, a LEFT JOIN so that students with no orders still appear, an aggregate, a GROUP BY and a COALESCE to turn the NULL totals into zeroes. That is a query most people would rather not retype, and getting one detail of it wrong in one place out of fifteen is how two screens in the same application end up disagreeing about how much a student has spent.

Name the columns explicitly. Every expression in a view — an aggregate, an arithmetic calculation, a CONCAT — needs an alias, because it becomes a column name that other people will type. SUM(o.amount) as a column name is unusable; total_spent is obvious. You can also declare the names in brackets after the view name if you prefer to see them all in one place.

Never write SELECT * inside a view. MySQL expands the * at the moment you create the view and stores the resulting explicit column list, so a column added to the table afterwards simply never appears in the view, and a column dropped from the table breaks it. Listing the columns yourself makes the behaviour visible instead of surprising, and it also documents what the view is promising to provide.

To change a view, MySQL and PostgreSQL both support CREATE OR REPLACE VIEW, but with an important restriction: the replacement must keep the same column names, in the same order, with compatible types. You may add columns to the end. You may not remove one or rename one — for that you must DROP the view and create it again, which means anything that depends on it stops working in between.

Example
-- One place for the logic everybody needs
CREATE VIEW student_order_summary AS
SELECT
    s.id                        AS student_id,
    s.name                      AS student_name,
    s.branch                    AS branch,
    COUNT(o.id)                 AS order_count,
    COALESCE(SUM(o.amount), 0)  AS total_spent,
    MAX(o.ordered_at)           AS last_order_at
FROM students s
LEFT JOIN orders o ON o.student_id = s.id
GROUP BY s.id, s.name, s.branch;

-- Now the complicated part is one word long
SELECT student_name, total_spent
FROM student_order_summary
WHERE total_spent > 5000
ORDER BY total_spent DESC;

-- A view can be joined like any other table
SELECT v.student_name, v.total_spent, b.hostel_block
FROM student_order_summary v
JOIN hostel_allocations b ON b.student_id = v.student_id;

-- Changing a view: same columns, same order, extra ones allowed
CREATE OR REPLACE VIEW active_students AS
SELECT id, name, email, branch, joined_on, phone
FROM students
WHERE is_active = TRUE;

-- Listing views
SHOW FULL TABLES WHERE Table_type = 'VIEW';    -- MySQL
SHOW CREATE VIEW student_order_summary;        -- MySQL: see the stored SQL
-- PostgreSQL: SELECT * FROM pg_views WHERE schemaname = 'public';

DROP VIEW IF EXISTS student_order_summary;
Notes
  • Views do not automatically break when the tables beneath them change — they break the next time somebody queries them, which may be weeks later and in front of a user. After renaming or dropping any column, check the views over that table. MySQL has CHECK TABLE some_view; for exactly this, and it is worth running after a migration.

Views as an Access Control Boundary

SQL's GRANT system can give somebody permission on a whole table, but it has no natural way to say 'these rows only'. A view fills that gap. You create a view that selects exactly the columns and rows a particular group is allowed to see, grant them SELECT on the view, and grant them nothing at all on the table underneath. They can query the view; they cannot go around it.

This works because of how permissions are evaluated. In MySQL a view is created with SQL SECURITY DEFINER by default, meaning the view's query runs with the privileges of the account that created it rather than the account running the query. So a reporting account with no rights on students can still read students_public, because the view's own privileges are what count. The alternative, SQL SECURITY INVOKER, requires the caller to have rights on the base tables too, which defeats the purpose here but is the safer default for a view whose only job is convenience.

PostgreSQL behaves the same way by default — a view runs with the owner's privileges — and version 15 added a security_invoker option for when you want the opposite. Whichever database you are on, the point is that this is a real security boundary and worth understanding rather than copying.

The classic uses are column masking and row filtering: a support view that omits password_hash, phone and address, or a view over orders restricted to the current branch. Do remember what a view does not do. It does not encrypt anything, and it does not help if the account querying it also has direct access to the table. A view is a boundary only when the permissions behind it are set up to make it one.

Example
-- Hide the columns nobody outside the team needs to see
CREATE VIEW students_public AS
SELECT id, name, branch, joined_on
FROM students;
-- email, phone, address and password_hash are simply not exposed

-- Restrict the rows as well
CREATE VIEW cse_students AS
SELECT id, name, email, joined_on
FROM students
WHERE branch = 'CSE';

-- Grant on the view, and nothing on the table (MySQL)
CREATE USER 'reporting'@'localhost' IDENTIFIED BY 'a-strong-password';
GRANT SELECT ON campus.students_public TO 'reporting'@'localhost';
-- Deliberately NOT granted: campus.students

-- Spell out which security model the view uses (MySQL)
CREATE SQL SECURITY DEFINER VIEW students_public_v2 AS
SELECT id, name, branch, joined_on FROM students;

CREATE SQL SECURITY INVOKER VIEW convenience_view AS
SELECT id, name FROM students WHERE is_active = TRUE;

-- PostgreSQL 15+: make a view run as the caller instead of the owner
-- CREATE VIEW students_public
--   WITH (security_invoker = true)
--   AS SELECT id, name, branch, joined_on FROM students;
Notes
  • Masking a column inside a view hides it from that view, not from the database. If the same account can also run SELECT * FROM students, the view has protected nothing. The protection comes from the GRANTs, and a view without the matching permission setup is documentation rather than security.

What a View Costs You

Because a view is a stored query, it costs whatever that query costs, every time. There is no caching layer, no saved result, no speed-up of any kind. A view is a readability and maintainability tool, and treating it as a performance tool is the most common misunderstanding about them.

Usually the optimiser handles this well. MySQL can process a view with the MERGE algorithm, folding the view's query into yours before planning, so SELECT * FROM active_students WHERE branch = 'CSE' becomes a single query with both conditions and can use an index on branch normally. When merging is possible, a view costs essentially nothing over writing the query by hand.

Merging is not always possible. If the view contains GROUP BY, DISTINCT, UNION, an aggregate, a window function or LIMIT, MySQL falls back to the TEMPTABLE algorithm: it runs the view's query in full, materialises the result into a temporary table, and only then applies your WHERE. This is where the surprise lives. A WHERE student_id = 42 against a summary view does not narrow the work down to one student — it aggregates every student in the table and then throws away all but one row. On a large table that is the difference between a query you can put on a page and one you cannot.

Nesting makes it worse, and quietly. A view built on a view built on a view reads beautifully and can generate a query plan nobody intended, because each layer may block the optimisation the layer above needed. Two levels is usually fine. When you find yourself at four, run EXPLAIN against the outermost one and look at what the database is actually doing.

Example
-- Merged: the view's WHERE and yours are combined into one query,
-- and an index on branch is used normally
CREATE VIEW active_students AS
SELECT id, name, email, branch FROM students WHERE is_active = TRUE;

SELECT name FROM active_students WHERE branch = 'CSE';
-- effectively:
-- SELECT name FROM students WHERE is_active = TRUE AND branch = 'CSE';

-- Materialised: the GROUP BY forces the whole view to be built first
SELECT * FROM student_order_summary WHERE student_id = 42;
-- The engine aggregates every student, then keeps one row.

-- If that is a query you run constantly, ask the question directly
SELECT
    s.id, s.name,
    COUNT(o.id)                AS order_count,
    COALESCE(SUM(o.amount), 0) AS total_spent
FROM students s
LEFT JOIN orders o ON o.student_id = s.id
WHERE s.id = 42
GROUP BY s.id, s.name;

-- Ask the optimiser what it is really doing
EXPLAIN SELECT * FROM student_order_summary WHERE student_id = 42;

-- MySQL lets you state the algorithm, though it may still override you
-- CREATE ALGORITHM = MERGE VIEW active_students AS ...
Notes
  • A view that is fast on your laptop with two thousand rows can be unusable in production with two million, and the reason is almost always that it materialises. Test views against realistic data volumes, and be especially careful with views that aggregate — they are the most useful ones to write and the most expensive ones to filter.

Writing Through a View, and the Alternatives

Some views accept INSERT, UPDATE and DELETE, and the changes land in the base table. The condition is that the database must be able to work out unambiguously which single base row each view row came from, so an updatable view reads from one table with no GROUP BY, no aggregate, no DISTINCT, no UNION and no subquery in the select list. Our active_students view qualifies; student_order_summary could not possibly, since one of its rows is a summary of many.

Even when it works there is a trap, and it has a name. An UPDATE through a filtered view can push a row out of that view's own WHERE clause — set is_active = FALSE through active_students and the row you just edited vanishes from it. The update succeeded and the data is fine; it is just no longer visible where you were working. Adding WITH CHECK OPTION to the view definition makes the database reject any write whose result would fall outside the view, which is usually what you meant.

In practice, most teams read through views and write to tables. It is simpler to reason about, it avoids arguments with the updatability rules, and it keeps the write path explicit. Reserve updatable views for the specific case where a view is somebody's only permitted access to a table.

Finally, know the neighbours. A CTE (WITH ... AS) is a view that exists for one statement — reach for it when the name is needed only inside that query. A materialized view genuinely stores its results, so it is fast to read and stale until refreshed; PostgreSQL and Oracle have them, MySQL does not. On MySQL the equivalent is to build a real summary table with an INSERT ... SELECT and refresh it on a schedule — which is exactly what a materialized view is, done by hand.

Example
-- Updatable: one table, a plain filter, no aggregation
UPDATE active_students SET branch = 'ECE' WHERE id = 42;
DELETE FROM active_students WHERE id = 43;

-- Not updatable, and could not be: this row summarises many rows
-- UPDATE student_order_summary SET total_spent = 0 WHERE student_id = 42;

-- The disappearing row. The update works; the row leaves the view.
UPDATE active_students SET is_active = FALSE WHERE id = 42;
SELECT * FROM active_students WHERE id = 42;   -- no rows

-- WITH CHECK OPTION refuses writes that would fall outside the view
CREATE OR REPLACE VIEW active_students AS
SELECT id, name, email, branch, is_active
FROM students
WHERE is_active = TRUE
WITH CHECK OPTION;

-- Now the same statement is rejected instead of silently hiding the row
-- UPDATE active_students SET is_active = FALSE WHERE id = 42;  -- error

-- A CTE: the same idea, scoped to a single statement
WITH branch_totals AS (
    SELECT branch, COUNT(*) AS students
    FROM students
    GROUP BY branch
)
SELECT branch, students FROM branch_totals WHERE students > 30;

-- PostgreSQL and Oracle store the results and need refreshing
-- CREATE MATERIALIZED VIEW student_order_summary_mv AS SELECT ...;
-- REFRESH MATERIALIZED VIEW student_order_summary_mv;

-- MySQL has no materialized views. Build a summary table instead.
CREATE TABLE student_order_summary_daily (
    student_id  INT PRIMARY KEY,
    order_count INT,
    total_spent DECIMAL(12,2),
    refreshed_at DATETIME
);
Notes
  • The trade a materialized view makes is freshness for speed, and you have to decide how stale is acceptable before you build one. A dashboard refreshed every night is fine. An account balance refreshed every night is not. If the answer must be current to the second, you want a plain view or a well-indexed query, not a stored copy.
Ask AI