PostgreSQL Views Aren’t Slow — Your Design Is
One of the most common complaints I hear is: “Views are slow.” And almost every time, the view isn’t the problem.
In PostgreSQL, a standard view is just a stored query. It doesn’t cache data, it doesn’t execute magically, and it doesn’t bypass the planner. If a view performs poorly, the underlying query would perform poorly too.
Let’s talk about how to design PostgreSQL views that scale — and when you should stop using a view altogether.
The First Rule: Views Don’t Store Data
A normal PostgreSQL view is expanded by the planner and executed as if you typed the query inline. That means performance is driven entirely by:
- Indexes on base tables
- Join conditions
- Filters and grouping
- Query structure
If a view is slow, your first instinct should be to run:
EXPLAIN ANALYZE
SELECT * FROM your_view;
PostgreSQL will show you exactly what it’s doing under the hood — no mystery involved.
Index the Base Tables (Not the View)
You cannot index a standard view — and that’s by design. PostgreSQL uses indexes on the underlying tables just as if the query were written directly.
Example:
CREATE OR REPLACE VIEW active_customers
AS SELECT customer_id,
first_name,
last_name,
active
FROM customer
WHERE active = 1;
If this view is slow, the fix isn’t rewriting the view — it’s indexing the filter:
CREATE INDEX idx_customer_active
ON customer(active);
Views don’t hide missing indexes. They expose them.
Stop Using SELECT *
SELECT * inside a view is one of the most common performance mistakes I see.
It forces PostgreSQL to fetch columns you may not need, prevents index-only scans, and creates brittle dependencies by coupling the view to every column in the underlying table.
Bad:
CREATE OR REPLACE VIEW rental_details AS
SELECT *
FROM rental r
JOIN customer c ON r.customer_id = c.customer_id;
Better:
CREATE OR REPLACE VIEW rental_details AS
SELECT
r.rental_id,
r.rental_date,
c.customer_id,
c.last_name
FROM rental r
JOIN customer c ON r.customer_id = c.customer_id;
Views should define a stable contract — not act as a wildcard shortcut.
Push Filters Down Whenever Possible
PostgreSQL is very good at pushing predicates into views — but it’s not magic. Certain constructs can block optimization.
If every query against a view filters on the same condition, that condition probably belongs in the view itself.
Also be cautious with:
- Unnecessary subqueries
- ORDER BY clauses inside views
- Volatile functions like now()
- Stacking views on top of views
If you need three layers of views to understand your query, the planner does too.
When Performance Actually Matters: Materialized Views
If you truly need speed — especially for reporting or analytics — a materialized view is the right tool.
Materialized views store results on disk and can be indexed like normal tables. The trade-off is that they must be refreshed.
CREATE MATERIALIZED VIEW monthly_revenue AS
SELECT
DATE_TRUNC('month', payment_date) AS month,
SUM(amount) AS revenue
FROM payment
GROUP BY 1;
Index it:
CREATE INDEX idx_monthly_revenue_month
ON monthly_revenue(month);
Refresh it:
REFRESH MATERIALIZED VIEW monthly_revenue;
This pattern is ideal for dashboards, summaries, and expensive aggregations where real-time data isn’t required.
Debug Views Like a Professional
Don’t guess. Measure.
EXPLAIN ANALYZE
SELECT * FROM your_view;
Look for sequential scans, bad row estimates, and missing indexes. A slow view is just a slow query wearing a disguise.
Conclusion
Views don’t make queries slower — bad design does. When used correctly, views simplify logic, improve maintainability, and give you cleaner, safer SQL.
Index the base tables. Avoid SELECT *. Use materialized views when speed matters. And always validate performance with EXPLAIN ANALYZE.
Once you internalize how PostgreSQL actually executes views, you’ll stop blaming them — and start using them confidently in production.