Module 8 · Programmability
Sign in with email or GitHub to spin up your own Postgres sandbox and run the queries for this lesson.
Sign in to start the sandboxNo GitHub? Create an account with email.
A view is a stored query that behaves like a table. You name a SELECT once, then read from that name anywhere — the query runs fresh each time, so a view always reflects the current base tables. A materialized view instead caches the result on disk, trading freshness for speed.
The seed is a small storefront: five customers (one marked inactive) and their orders.
SELECT * FROM customers ORDER BY id;
Say you keep filtering to active customers. Instead of repeating the WHERE clause everywhere, wrap the query in a view once:
CREATE VIEW active_customers AS
SELECT id, name, email, country
FROM customers
WHERE is_active;
Now query the view exactly like a table — no data was copied; active_customers is a virtual table backed by the query above:
SELECT name, country FROM active_customers ORDER BY name;
Margaret is gone because she is inactive. A view gives you three things at once: readability (a name for a gnarly query), a stable interface (callers depend on the view, not the underlying columns), and a cheap way to hide columns — notice the view never exposes is_active, and you could just as easily omit an email or a salary.
A view stores the query, not the rows, so it re-runs every time. Change a base table and the view reflects it instantly — no refresh step:
UPDATE customers SET is_active = false WHERE name = 'Linus Torvalds';
SELECT name, country FROM active_customers ORDER BY name;
Linus vanished the moment his row changed. Let's put him back so later steps line up:
UPDATE customers SET is_active = true WHERE name = 'Linus Torvalds';
Because a view is just a query with a name, you can join it, filter it, and aggregate over it like any table. Here we join active_customers to orders:
SELECT c.name, count(o.id) AS orders
FROM active_customers c
JOIN orders o ON o.customer_id = c.id
GROUP BY c.name
ORDER BY c.name;
A view over a single table with no aggregation, DISTINCT, or GROUP BY is updatable — INSERT, UPDATE, and DELETE on the view flow through to the base table. Watch an update to active_customers land on customers:
UPDATE active_customers SET country = 'GB' WHERE name = 'Ada Lovelace';
SELECT name, country FROM customers WHERE name = 'Ada Lovelace';
But an updatable view has a sharp edge. Nothing stops you from writing a row that no longer satisfies the view's own WHERE — the row lands in the base table and then disappears from the view. WITH CHECK OPTION closes that gap: it rejects any write whose result would fall outside the view. Recreate the view with the guard, this time exposing is_active so we can write to it, using CREATE OR REPLACE VIEW (edits a view in place, no DROP needed):
CREATE OR REPLACE VIEW active_customers AS
SELECT id, name, email, country, is_active
FROM customers
WHERE is_active
WITH CHECK OPTION;
Now try to deactivate someone through the view — the new row would have is_active = false, which contradicts the view's filter, so Postgres refuses:
UPDATE active_customers SET is_active = false WHERE name = 'Ada Lovelace';
You'll see new row violates check option. The guard keeps the view's contract honest.
A plain view re-runs its query on every read. For an expensive aggregation over millions of rows, that cost repeats every single time. A materialized view runs the query once, stores the resulting rows on disk, and serves them straight from storage — reads become as cheap as scanning a small table:
CREATE MATERIALIZED VIEW country_revenue AS
SELECT c.country, count(o.id) AS orders, sum(o.amount) AS revenue
FROM customers c
JOIN orders o ON o.customer_id = c.id
GROUP BY c.country;
SELECT * FROM country_revenue ORDER BY revenue DESC;
The catch: the stored rows are a snapshot. Add an order and the materialized view does not change — it is now stale:
INSERT INTO orders (customer_id, placed_on, amount) VALUES (1, '2026-04-01', 1000.00);
SELECT * FROM country_revenue ORDER BY revenue DESC;
UK revenue still shows the old total. You re-run the underlying query on demand with REFRESH MATERIALIZED VIEW:
REFRESH MATERIALIZED VIEW country_revenue;
SELECT * FROM country_revenue ORDER BY revenue DESC;
Now UK reflects the new order. A plain REFRESH takes an exclusive lock, blocking readers while it rebuilds. REFRESH MATERIALIZED VIEW CONCURRENTLY avoids that lock so queries keep working during the rebuild — but it requires a UNIQUE index on the matview, and it does more work internally. Add the index, then refresh concurrently:
CREATE UNIQUE INDEX country_revenue_country ON country_revenue (country);
REFRESH MATERIALIZED VIEW CONCURRENTLY country_revenue;
That index does double duty: besides enabling concurrent refresh, it makes lookups by country on the matview fast, exactly like an index on a real table.
When to use which: reach for a plain view when the query is cheap and you need it always fresh. Reach for a materialized view when the query is expensive — big aggregations, heavy joins — and you can tolerate results that are a few minutes (or hours) old, refreshing on a schedule.
Build a per-customer summary as a plain view called customer_totals, with columns name, order_count, and lifetime_value (the count and sum of each customer's orders). A view fits here: it should always reflect the latest orders. Try it before peeking — here's one way:
CREATE VIEW customer_totals AS
SELECT c.name,
count(o.id) AS order_count,
sum(o.amount) AS lifetime_value
FROM customers c
JOIN orders o ON o.customer_id = c.id
GROUP BY c.name;
Read it back like a table:
SELECT name, order_count, lifetime_value
FROM customer_totals
ORDER BY lifetime_value DESC;
CREATE VIEW name AS SELECT ….WITH CHECK OPTION rejects writes that would push a row out of the view; CREATE OR REPLACE VIEW edits one in place.REFRESH MATERIALIZED VIEW. REFRESH … CONCURRENTLY needs a UNIQUE index and avoids locking readers; index a matview for fast lookups too.Up next: functions — packaging logic in the database.