PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 32: Views

Views and materialized views

Level 6: Design and Security20 min

A view is a stored query: always current, no storage, and the planner merges it into the calling query. A materialized view stores the result, which makes reads fast and data stale until you refresh it.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Create views, including updatable views with CHECK OPTION
  • Create, index and refresh materialized views, concurrently when possible
  • Choose between a view, a materialized view and a summary table

conceptThe concept

CREATE VIEW stores a query. Selecting from it is like inlining the query, so filters from outside are pushed down. Simple views (one table, no aggregates) are automatically updatable; WITH CHECK OPTION rejects inserts and updates that would produce rows the view cannot see. Since 15, security_invoker makes a view check permissions as the calling user.

CREATE MATERIALIZED VIEW runs the query once and stores rows. REFRESH MATERIALIZED VIEW recomputes it under an exclusive lock that blocks reads; REFRESH ... CONCURRENTLY computes a diff and applies it while reads continue, and requires a unique index on the materialized view.

whyWhy it matters

Views give stable interfaces: you can restructure tables underneath while reports keep working. Materialized views turn a 10-second dashboard query into a 5-millisecond lookup, as long as minutes-old data is acceptable.

Use caseReal-world scenario

The executive dashboard shows revenue per country per month. A materialized view refreshed every 10 minutes serves it instantly, and the BI tool reads a view that hides internal columns.

Try itExamples

Run and edit these in the interactive lesson.

A view that hides details
BEGIN;
CREATE VIEW customer_summary AS
SELECT c.id, c.name, c.country, c.tier,
       count(o.id) AS orders,
       coalesce(sum(o.total) FILTER (WHERE o.status <> 'cancelled'), 0) AS lifetime_value
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id;
SELECT * FROM customer_summary WHERE country = 'IN' ORDER BY lifetime_value DESC LIMIT 5;
ROLLBACK;
Updatable view with CHECK OPTION
BEGIN;
CREATE VIEW german_customers AS
SELECT id, name, email, city, country, created_at FROM customers WHERE country = 'DE'
WITH CHECK OPTION;
UPDATE german_customers SET city = 'Hamburg' WHERE id = (SELECT min(id) FROM customers WHERE country = 'DE');
INSERT INTO german_customers (name, email, city, country, created_at) VALUES ('Eve', 'eve@example.com', 'Paris', 'FR', now());
The update through the view works. The insert of a French customer violates the view's WHERE clause, so CHECK OPTION rejects it.
Materialized view with concurrent refresh
BEGIN;
CREATE MATERIALIZED VIEW revenue_by_month AS
SELECT date_trunc('month', o.created_at)::date AS month, c.country, sum(o.total) AS revenue, count(*) AS orders
FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE o.status <> 'cancelled'
GROUP BY 1, 2;
CREATE UNIQUE INDEX ON revenue_by_month (month, country);
SELECT * FROM revenue_by_month WHERE country = 'UK' ORDER BY month DESC LIMIT 3;
UPDATE orders SET total = total + 1000 WHERE id = (SELECT max(id) FROM orders);
REFRESH MATERIALIZED VIEW CONCURRENTLY revenue_by_month;
SELECT * FROM revenue_by_month WHERE month = '2025-12-01' ORDER BY revenue DESC;
ROLLBACK;

Avoid / preferDo this, not that

Avoid
CREATE VIEW recent_orders AS SELECT * FROM orders WHERE created_at > now() - interval '7 days';
Prefer
CREATE VIEW recent_orders AS
SELECT id, customer_id, status, total, created_at
FROM orders WHERE created_at > now() - interval '7 days';

SELECT * in a view is expanded when the view is created, so new columns never appear and the view's shape is unclear. List the columns.

Avoid
-- view on view on view on view
Prefer
-- one or two layers, each with a clear purpose

Deeply stacked views hide expensive joins and make plans and dependency changes hard to follow.

PitfallsCommon mistakes

  • Stacking views on views on views. The final query becomes huge and hard to tune.
  • Refreshing a large materialized view without CONCURRENTLY while users read it; they block for the whole refresh.
  • Expecting a materialized view to update itself. It only changes when REFRESH runs (schedule it with cron or pg_cron).

LimitsWhen not to use it

Do not use a materialized view for data that must be current to the second; it is only as fresh as its last refresh.

SecuritySecurity notes

Views run with the owner's privileges by default, which bypasses the caller's row-level security on base tables. Since PostgreSQL 15, WITH (security_invoker = true) makes a view check the caller's privileges and policies.

ChallengeTry it yourself

Create a security_invoker view with each customer's number of non-cancelled orders and revenue, and query the top three customers by revenue.

Show one solution
One solution
BEGIN;
CREATE VIEW customer_revenue WITH (security_invoker = true) AS
SELECT c.id, c.name, count(o.id) AS orders, coalesce(sum(o.total), 0) AS revenue
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.status <> 'cancelled'
GROUP BY c.id;
SELECT name, orders, revenue FROM customer_revenue ORDER BY revenue DESC LIMIT 3;
ROLLBACK;

InterviewQuestions you should be able to answer

View or materialized view?
A view is a saved query with no storage: always current, and the planner merges it into each query, so it is as fast as the underlying query. A materialized view stores the result: reads are fast and can be indexed, but data is stale until REFRESH, which costs the full query each time. Use materialized views for expensive aggregates where some staleness is acceptable; use REFRESH CONCURRENTLY (needs a unique index) to avoid blocking readers.
What does WITH CHECK OPTION do?
On an updatable view, it rejects INSERTs and UPDATEs that would produce rows not visible through the view's WHERE clause. Without it, you could insert or move rows through the view that then disappear from it. LOCAL checks only this view; CASCADED (default) also checks underlying views.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation