There’s a particular moment in every database project where the same complicated join keeps showing up in query after query, and someone on the team finally says, “why don’t we just save this as something we can reuse?” That’s usually the moment views enter the picture. I use them constantly — for simplifying complex queries, for controlling access to sensitive columns, and for giving reporting tools a clean, stable interface to query against. In this guide, I’ll cover everything from the basic syntax to materialized views and the gotchas that catch people off guard.
What Is a View in PostgreSQL?
A view is a stored SQL query that behaves like a virtual table. It doesn’t store data itself (with one important exception, covered later) — instead, every time you query a view, PostgreSQL runs the underlying query and returns the result as if it were a table.
Views are useful for:
- Simplifying complex or repetitive queries.
- Presenting a controlled subset of columns or rows to certain users.
- Providing a stable interface even if the underlying table structure changes.
- Encapsulating business logic in a single, reusable place.
Basic Syntax
CREATE VIEW view_name AS
SELECT column1, column2, ...
FROM table_name
WHERE condition;
Here’s a simple example:
CREATE VIEW active_employees AS
SELECT id, name, department, hire_date
FROM employees
WHERE status = 'active';
Now you can query it just like a table:
SELECT * FROM active_employees WHERE department = 'Engineering';
PostgreSQL takes care of combining your view’s query with your SELECT statement’s conditions behind the scenes.
Creating Views with Joins
Views really shine when they hide the complexity of multi-table joins:
CREATE VIEW employee_details AS
SELECT
e.id,
e.name,
d.department_name,
s.salary
FROM employees e
JOIN departments d ON e.department_id = d.id
JOIN salaries s ON e.id = s.employee_id;
Now anyone querying employee_details gets a clean, joined result without needing to know or repeat the join logic themselves:
SELECT name, department_name FROM employee_details WHERE salary > 80000;
Creating Views with Aggregations
CREATE VIEW department_headcount AS
SELECT department, COUNT(*) AS employee_count
FROM employees
GROUP BY department;
SELECT * FROM department_headcount ORDER BY employee_count DESC;
Updating and Replacing Views
If you need to modify a view’s definition, you have two options. CREATE OR REPLACE VIEW works as long as you’re not changing the output column names, order, or removing columns:
CREATE OR REPLACE VIEW active_employees AS
SELECT id, name, department, hire_date, email
FROM employees
WHERE status = 'active';
If you need to remove a column or fundamentally restructure the view, you’ll need to drop and recreate it:
DROP VIEW active_employees;
CREATE VIEW active_employees AS
SELECT id, name, hire_date
FROM employees
WHERE status = 'active';
Be careful with DROP VIEW if other views or objects depend on it — PostgreSQL will block the drop unless you use CASCADE, which then drops those dependents too.
Updatable Views
Here’s something that surprises people who assume views are always read-only: PostgreSQL supports automatically updatable views for simple cases. If a view is based on a single table (no joins, aggregates, DISTINCT, GROUP BY, or set operations), you can INSERT, UPDATE, and DELETE directly through it:
CREATE VIEW active_employees AS
SELECT id, name, department, hire_date
FROM employees
WHERE status = 'active';
UPDATE active_employees SET department = 'Sales' WHERE id = 42;
This actually updates the underlying employees table. For more complex views (joins, aggregates), you’d need to define INSTEAD OF triggers to make them updatable, which gives you full control over what happens when someone tries to insert or update through the view.
CREATE OR REPLACE FUNCTION update_employee_details()
RETURNS TRIGGER AS $$
BEGIN
UPDATE employees SET name = NEW.name WHERE id = NEW.id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER employee_details_update
INSTEAD OF UPDATE ON employee_details
FOR EACH ROW EXECUTE FUNCTION update_employee_details();
Views with Security: WITH CHECK OPTION
If you’re using an updatable view to restrict what rows a role can modify, WITH CHECK OPTION prevents inserts or updates that would create rows falling outside the view’s WHERE clause:
CREATE VIEW active_employees AS
SELECT id, name, department, status
FROM employees
WHERE status = 'active'
WITH CHECK OPTION;
Without this, someone could update a row through the view in a way that changes its status to 'inactive', and the row would simply vanish from the view’s results — which is often not what you want. WITH CHECK OPTION blocks that kind of update outright.
Security Barrier Views
For views used specifically to restrict access to sensitive data, security_barrier prevents a subtle vulnerability where a malicious or careless function used in a filtering condition could leak data from rows that should be hidden:
CREATE VIEW public_employee_info WITH (security_barrier = true) AS
SELECT id, name, department
FROM employees
WHERE department != 'Executive';
Materialized Views
Unlike regular views, a materialized view actually stores the query result physically on disk, like a snapshot. This is useful when the underlying query is expensive and doesn’t need to reflect real-time data.
CREATE MATERIALIZED VIEW monthly_sales_summary AS
SELECT
date_trunc('month', sale_date) AS month,
SUM(amount) AS total_sales
FROM sales
GROUP BY date_trunc('month', sale_date);
Querying it is fast because the data is already computed:
SELECT * FROM monthly_sales_summary ORDER BY month;
But the data goes stale as the underlying sales table changes. You need to refresh it manually or on a schedule:
REFRESH MATERIALIZED VIEW monthly_sales_summary;
To avoid locking readers out during the refresh, use:
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_sales_summary;
Note that CONCURRENTLY requires a unique index on the materialized view first:
CREATE UNIQUE INDEX idx_monthly_sales_month ON monthly_sales_summary(month);
Common Use Cases
- Simplifying recurring complex joins for reporting teams.
- Hiding sensitive columns (like salary or SSNs) from certain roles by exposing only a filtered view.
- Providing a stable API-like interface to applications, even as underlying tables get refactored.
- Precomputing expensive aggregations with materialized views for dashboards.
- Encapsulating multi-step business logic (e.g., “active customers with an order in the last 90 days”) in one reusable definition.
Troubleshooting Common Issues
“cannot drop view because other objects depend on it” — another view, function, or table constraint references this one. Use DROP VIEW view_name CASCADE if you’re sure, but check dependencies first with:
SELECT dependent_ns.nspname, dependent_view.relname
FROM pg_depend
JOIN pg_rewrite ON pg_depend.objid = pg_rewrite.oid
JOIN pg_class AS dependent_view ON pg_rewrite.ev_class = dependent_view.oid
JOIN pg_class AS source_table ON pg_depend.refobjid = source_table.oid
JOIN pg_namespace dependent_ns ON dependent_view.relnamespace = dependent_ns.oid
WHERE source_table.relname = 'employees';
View is slow — remember a regular view is just a stored query; it inherits all the performance characteristics (and problems) of the underlying SQL. If it’s slow, optimize the query itself (indexes, join order) or consider converting it to a materialized view if real-time freshness isn’t required.
“cannot update view” errors — the view isn’t automatically updatable because it involves a join, aggregate, or DISTINCT. You’ll need INSTEAD OF triggers, or update the base tables directly.
Materialized view shows stale data — this is expected; it only reflects data as of the last REFRESH. Schedule refreshes with a cron job or pg_cron extension if you need it kept current.
Best Practices
- Use views to encapsulate logic that’s repeated across multiple queries, rather than copy-pasting the same joins everywhere.
- Reach for materialized views only when query cost genuinely justifies the staleness trade-off — they’re not a free performance win.
- Add
WITH CHECK OPTIONon any updatable view used for row-level access control. - Use
security_barrieron views meant to restrict access to sensitive data. - Name views clearly to distinguish them from base tables (a
v_or_viewnaming convention helps some teams, though it’s a matter of preference). - Document what each view is for, especially in larger teams — views can quietly become load-bearing infrastructure that nobody remembers building.
Wrapping Up
Views are one of those PostgreSQL features that feel almost too simple at first — just a saved query — until you realize how much complexity they can absorb on behalf of everyone querying your database. Whether you’re simplifying joins, controlling access to sensitive data, or precomputing expensive aggregations with materialized views, the underlying idea is the same: define the logic once, reuse it everywhere, and keep your actual queries clean.
