Database Views: Simple, Materialized, and Updateable Views
Database Views: Simple, Materialized, and Updateable Views
A database view is a stored query that behaves like a virtual table. Views abstract complexity, enforce security, and provide a stable API over changing schemas. PostgreSQL supports three categories: simple views, materialized views, and updateable views.
Simple (Virtual) Views
A simple view does not store data; it runs the underlying query each time it is referenced. Think of it as a saved `SELECT` statement.
CREATE VIEW active_users AS
SELECT u.id, u.email, u.created_at,
COUNT(o.id) AS order_count,
COALESCE(SUM(o.total), 0) AS lifetime_value
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.deleted_at IS NULL
GROUP BY u.id, u.email, u.created_at;
Querying the view is identical to querying a table:
SELECT * FROM active_users WHERE lifetime_value > 1000 ORDER BY lifetime_value DESC;
The planner inlines the view definition into the outer query, so the optimizer can push filters and joins into the underlying scans. A simple view adds almost zero overhead.
Use cases for simple views:
* **Row-level security**: Expose only specific columns or filtered rows to certain roles.
* **Schema abstraction**: Rename or restructure columns without breaking client applications.
* **Reusable joins**: Encapsulate multi-table aggregations that are queried frequently.
Materialized Views
A materialized view physically stores the result set. Queries against it are fast because they read pre-computed data rather than executing the full query.
CREATE MATERIALIZED VIEW daily_sales_summary AS
SELECT DATE(o.order_date) AS day,
p.category,
COUNT(*) AS order_count,
SUM(oi.quantity * oi.unit_price) AS revenue
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
GROUP BY DATE(o.order_date), p.category
WITH DATA;
The materialized view must be refreshed to reflect new data:
REFRESH MATERIALIZED VIEW daily_sales_summary;
In PostgreSQL, `REFRESH MATERIALIZED VIEW` takes an `ACCESS EXCLUSIVE` lock, blocking concurrent reads. The `CONCURRENTLY` option avoids this but requires a unique index:
CREATE UNIQUE INDEX ON daily_sales_summary (day, category);
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_sales_summary;
Materialized views shine when:
* The underlying query aggregates millions of rows and runs for seconds or minutes.
* Slightly stale data (minutes or hours) is acceptable.
* The result set is small enough to be stored efficiently.
The trade-off is staleness. Between refreshes, queries see snapshots that may differ from the base tables. Design your refresh schedule around business tolerance for latency.
Updateable Views
PostgreSQL automatically makes simple views updateable if they meet certain conditions. The view must reference exactly one table (or a single-table `UNION ALL` in some cases), include the primary key, and exclude aggregates, window functions, and `DISTINCT`.
CREATE VIEW active_orders AS
SELECT id, user_id, total, status, order_date
FROM orders
WHERE deleted_at IS NULL;
-- This INSERT works because the view is updateable
INSERT INTO active_orders (user_id, total, status, order_date)
VALUES (42, 99.99, 'pending', CURRENT_DATE);
For complex views that are not automatically updateable, you can use `INSTEAD OF` triggers:
CREATE VIEW order_summary AS
SELECT o.id, o.user_id, o.total,
COALESCE(AVG(oi.unit_price), 0) AS avg_item_price
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id, o.user_id, o.total;
CREATE OR REPLACE FUNCTION insert_order_summary()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO orders (id, user_id, total)
VALUES (NEW.id, NEW.user_id, NEW.total);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER instead_of_insert
INSTEAD OF INSERT ON order_summary
FOR EACH ROW EXECUTE FUNCTION insert_order_summary();
Performance Trade-offs
| Aspect | Simple View | Materialized View | |--------|-------------|-------------------| | Storage | None | Full result set | | Query speed | Depends on base query | Fast (pre-computed) | | Data freshness | Real-time | Stale until refresh | | Write overhead | None | Refresh cost | | Indexed columns | Base table indexes | Materialized view indexes |
View Security
Views are a powerful security tool. You can grant `SELECT` on a view without granting access to the underlying tables:
REVOKE ALL ON users FROM app_readonly;
GRANT SELECT ON active_users TO app_readonly;
With `security_barrier` views, PostgreSQL prevents leaky predicate pushdowns that could expose hidden rows:
CREATE VIEW secure_employees WITH (security_barrier) AS
SELECT * FROM employees WHERE active = true;
Choose wisely between simple and materialized views. Simple views suit OLTP workloads where freshness matters. Materialized views fit analytical dashboards, reporting, and any query where millisecond latency justifies a few minutes of staleness.