Blog Post

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.