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.