SQL Views

5.Mike's Daily Report, Automated

M

In this chapter

We'll save queries permanently as VIEWs — covering simple and complex views, views built on subqueries and CTEs, nested views, security through views, materialized vs. normal views, and what it actually takes to alter, drop, or write through one.

26–30 min

The Problem in Real Life

Mike wants the same report every single morning: how much did GreenMart make yesterday, and which products actually sold. Sarah has written that exact query more times than she can count now — once with a JOIN, once with a GROUP BY, once as a CTE. Every morning, Mike asks again, and every morning, someone has to go find it.

Sarah realizes the query itself has never actually been the hard part. Nobody's ever had a way to just save it — permanently, under a name, so asking for it again is as easy as asking for any other table.

S

I keep answering the same question. I should just save the answer.

Sarah

Retyping the Same Query vs. Naming It Once, For Good

The same complex query, every single day

Retyping (or re-finding) the same multi-table query daily is real, avoidable work.

A view isn't a copy of the data

Querying a view re-runs its underlying SELECT fresh every time — it's never looking at a stale snapshot, unlike a materialized one.

Writing through a view isn't automatic

Even a simple, single-table view is read-only in SQLite by default — a write needs an INSTEAD OF trigger to actually reach the real table.

A view still needs the tables underneath it

Dropping a table a view depends on breaks every query against that view, even though the view itself was never touched.

What Is a VIEW, Really?

A VIEW saves a query permanently under a name, so it can be queried exactly like a real table from then on. Unlike a CTE, which only exists for the one query it's defined inside, a view lives in the schema itself — any future query can use it, not just the one it was written for.

A view stores no data of its own — querying it re-runs the underlying SELECT fresh, every single time, against whatever the real tables currently contain. That one fact explains almost everything else about how views behave, including a few real surprises. Here's each idea, one query at a time:

CREATE VIEW saves a query permanently under a name, queryable exactly like a real table. A view built from a JOIN and a GROUP BY like this one is usually called a complex view.

CREATE VIEW — Wrapping the Daily Revenue Query
CREATE VIEW DailyRevenue AS
SELECT s.SaleDate, SUM(p.Price) AS Revenue
FROM Sales s
JOIN Products p ON s.ProductID = p.ProductID
GROUP BY s.SaleDate;

This is the exact JOIN-and-GROUP-BY logic from chapters 2 and 3, saved permanently under one name instead of retyped every morning.

Querying the View — Looks Exactly Like a Table
SELECT * FROM DailyRevenue
ORDER BY SaleDate;

No JOIN, no GROUP BY, no mention of Sales or Products anywhere in this query — DailyRevenue quietly handles all of that underneath.

A view's underlying query can filter with WHERE just like any other SELECT. A single-table view with no JOIN or aggregation, like this one, is usually called a simple view — compare it to DailyRevenue above, a complex view.

View with WHERE — a Simple View
CREATE VIEW CustomersWithBalance AS
SELECT CustomerID, Name, Phone, BalanceDue
FROM Customers
WHERE BalanceDue > 0;

Only the five customers who actually owe money show up here, permanently, without WHERE ever needing to be retyped.

View with HAVING
CREATE VIEW PopularProducts AS
SELECT p.Name, COUNT(*) AS UnitsSold
FROM Sales s
JOIN Products p ON s.ProductID = p.ProductID
GROUP BY p.Name
HAVING COUNT(*) > 1;

The exact HAVING logic from chapter 3, saved permanently — only products that sold more than once ever appear in this view.

A view's underlying query can use a subquery too — this reuses the exact scalar subquery from the previous chapter.

A View Built on a Subquery — Always the Current Average
CREATE VIEW CustomersAboveAverage AS
SELECT Name, BalanceDue
FROM Customers
WHERE BalanceDue > (SELECT AVG(BalanceDue) FROM Customers);

The average recalculates fresh every time this view is queried, using whatever balances exist at that moment — never the average from whenever the view was first created.

A view's underlying query can include a CTE too — this is the exact TopSeller logic from the previous chapter, now saved permanently.

A View Built on a CTE — Always the Current Top Seller
CREATE VIEW TopSellerView AS
WITH ProductSales AS (
SELECT p.Name, COUNT(*) AS UnitsSold
FROM Sales s
JOIN Products p ON s.ProductID = p.ProductID
GROUP BY p.Name
)
SELECT Name FROM ProductSales ORDER BY UnitsSold DESC LIMIT 1;

Query this view any morning, and it always names whichever product is winning right now — never a name frozen from the day the view was created.

Unlike a CTE (only exists for one query), a VIEW persists in the schema for any future query to reuse.

A Second View — Best Sellers, Always Up to Date
CREATE VIEW BestSellers AS
SELECT p.Name, COUNT(*) AS UnitsSold
FROM Sales s
JOIN Products p ON s.ProductID = p.ProductID
GROUP BY p.Name;

Mike's two daily questions — revenue and best sellers — are now two permanent views, each queryable on its own.

A view can be built directly on top of another view — a nested view. Querying HighRevenueDays quietly runs DailyRevenue's own JOIN and GROUP BY underneath, two layers deep, then filters the result.

A Nested View — Built Directly on Top of Another View
CREATE VIEW HighRevenueDays AS
SELECT * FROM DailyRevenue WHERE Revenue > 10;

Only two days — Aug 28th and Aug 29th — currently clear 10 in revenue.

A view can expose only the columns a consumer actually needs — this is security through views, one of a view's real practical advantages, alongside data abstraction (hiding the underlying JOIN/GROUP BY complexity from whoever queries it).

Security Through Views
CREATE VIEW PublicCustomerDirectory AS
SELECT Name, Phone
FROM Customers;

Anyone querying PublicCustomerDirectory literally cannot see BalanceDue or Email — not because of a promise or a permission check, but because the view's own definition never selected those columns in the first place.

A view stores no data of its own — every query against it re-runs the underlying SELECT fresh.

A New Sale Comes In — No View Changes Needed
INSERT INTO Sales (SaleID, CustomerID, ProductID, SaleDate)
VALUES (213, 103, 1, '2026-08-29');
SELECT * FROM DailyRevenue ORDER BY SaleDate;

Nothing about DailyRevenue was touched, but querying it again already includes this brand-new sale — a view is never looking at yesterday's snapshot.

A materialized view is a view that DOES store its results, unlike a normal view. SQLite has no native materialized view feature at all, so the closest simulation is a real table like this one — frozen the moment it's created, and never automatically refreshed.

Materialized View vs. Normal View
CREATE TABLE DailyRevenueSnapshot AS
SELECT s.SaleDate, SUM(p.Price) AS Revenue
FROM Sales s
JOIN Products p ON s.ProductID = p.ProductID
GROUP BY s.SaleDate;
INSERT INTO Sales (SaleID, CustomerID, ProductID, SaleDate)
VALUES (214, 105, 6, '2026-08-29');
SELECT * FROM DailyRevenue ORDER BY SaleDate;
SELECT * FROM DailyRevenueSnapshot ORDER BY SaleDate;

After the new sale, DailyRevenue's Aug 29th total already moved. DailyRevenueSnapshot's Aug 29th total is stuck at whatever it was the instant CREATE TABLE ran — exactly the tradeoff a real materialized view makes: faster reads, staler data, until something explicitly refreshes it.

In most databases, a simple, single-table view like CustomersWithBalance is usually updatable — writes pass straight through to the real table. An aggregate view like BestSellers is essentially always non-updatable, since there's no sensible way to turn a change to one summary row back into changes on the individual rows underneath it. SQLite is stricter than most: every view is read-only by default, updatable-shaped or not, unless it has an INSTEAD OF trigger telling it exactly how to translate a write against the view into a real write against the underlying table.

Updatable vs. Non-Updatable Views — Making a Write Actually Work
CREATE TRIGGER UpdateCustomerBalance
INSTEAD OF UPDATE ON CustomersWithBalance
BEGIN
UPDATE Customers
SET BalanceDue = NEW.BalanceDue
WHERE CustomerID = OLD.CustomerID;
END;
UPDATE CustomersWithBalance SET BalanceDue = 100 WHERE CustomerID = 101;
SELECT * FROM Customers WHERE CustomerID = 101;

Priya's real BalanceDue in Customers is now 100, changed entirely through a write against the view, not the table itself.

WITH CHECK OPTION ensures a write made through a view can't create a row that would violate the view's own WHERE condition — inserting a customer with no email through this view would be rejected outright, instead of silently succeeding and then vanishing from the view. Most databases (PostgreSQL, MySQL, Oracle) support it; SQLite does not, so this is reference syntax only, not something that runs in this Playground.

WITH CHECK OPTION
-- Not supported by SQLite -- shown as reference syntax only:
--
-- CREATE VIEW CustomersWithEmail AS
-- SELECT * FROM Customers
-- WHERE Email IS NOT NULL
-- WITH CHECK OPTION;

Without CHECK OPTION, a write that violates the view's WHERE clause would just quietly disappear from that view's results the next time it's queried — which is often the more surprising, and worse, behavior.

Most databases support CREATE OR REPLACE VIEW to redefine an existing view in one step. SQLite doesn't — altering a view here always means DROP VIEW first, then CREATE VIEW again with the new definition.

Altering (and Dropping) a View
DROP VIEW BestSellers;
CREATE VIEW BestSellers AS
SELECT p.Name, COUNT(*) AS UnitsSold, SUM(p.Price) AS Revenue
FROM Sales s
JOIN Products p ON s.ProductID = p.ProductID
GROUP BY p.Name;

BestSellers now includes a Revenue column it never had before — the only way to add that in SQLite is to drop the old definition and create it fresh.

Notice what a view is actually saving throughout all of this: not the data, and not a result — just the query itself. That's exactly why a new sale shows up the moment DailyRevenue is queried again, with zero extra steps, and exactly why DailyRevenueSnapshot never budges on its own — it saved actual rows, not a query.

One more real limitation worth having ready: a view's own performance is only ever as good as the query underneath it. A simple view over one table costs nothing extra. A deeply nested view built on views built on views, each re-running its own JOIN and GROUP BY every single time, can get genuinely slow — which is exactly the tradeoff a materialized view (or a real snapshot table, in SQLite's case) exists to avoid, at the cost of possibly-stale data.

GreenMart finally has reports that mostly answer themselves. Every skill from this entire Act — JOIN, GROUP BY, subqueries, and now views — comes together in the checkpoint next.

Key Takeaway

A VIEW saves a query permanently under a name, so it can be queried just like a table — but it stores no data itself, re-running its underlying SELECT fresh every single time, which is also exactly why writing through one, or freezing its results, needs extra machinery most people don't expect.

Why This Matters

Nearly every real dashboard, admin panel, and daily report a business relies on is a VIEW (or several) sitting quietly behind the scenes. Beyond convenience, views are a genuine security and abstraction tool — exposing only the columns a consumer needs — and understanding where they stop being free (updatability, staleness, nested performance) is exactly the kind of practical judgment real production systems depend on.

GreenMart finally has reports that update themselves, views that protect sensitive columns, and a real answer for what to do when a view needs to change. Next is the checkpoint — building GreenMart's first real dashboard, using everything this Act has taught.

Next