---
title: "View vs Materialized View: A Saved Question vs a Saved Answer"
description: "You wrapped the slow query in a view and believed it made something faster. It did not. A view stores no data — it stores SQL text, so every SELECT re-runs the query from scratch. A materialized view stores the answer as a real table on disk you can index, which turns 40 seconds into milliseconds and charges you freshness. Here is exactly what each one is, why a materialized view is neither a cache nor an index, what REFRESH really costs (an exclusive lock, or CONCURRENTLY and a unique index), and the one question you must answer before you ship one."
keywords: "view vs materialized view, materialized view, PostgreSQL materialized view, REFRESH MATERIALIZED VIEW, CONCURRENTLY, SQL view, database performance, stale data, staleness budget, precomputation, dashboard query, data engineering, system design"
created_at: "2026-08-16T20:30:00"
post_type: "anonym_post"
content_type: "technical_article"
---

![Banner](./banner.webp)

<div style="display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; padding: 1rem 1.25rem; margin: 1.5rem 0 2rem; background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); border: 1px solid #e2e8f0; border-radius: 12px;">
  <span style="font-size: 0.95rem; font-weight: 600; color: #475569; margin-right: 0.25rem;">Prefer to watch?</span>
  <a href="https://youtube.com/shorts/VQIdisCiOqg" target="_blank" rel="noopener noreferrer" style="display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.55rem 1rem; border-radius: 8px; background: #ff0000; color: #ffffff; font-size: 0.875rem; font-weight: 600; text-decoration: none;">⚡ The 98-second version</a>
  <a href="https://t.me/SoftwareEngineerBlog" target="_blank" rel="noopener noreferrer" style="display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.55rem 1rem; border-radius: 8px; background: #229ed9; color: #ffffff; font-size: 0.875rem; font-weight: 600; text-decoration: none;">✈ Telegram</a>
</div>

The dashboard takes forty seconds to load. Someone opens a ticket. Someone else — probably you — reaches for the fix everybody reaches for:

```sql
CREATE VIEW daily_revenue AS
SELECT
  date_trunc('day', o.created_at) AS day,
  c.region,
  count(*)                        AS orders,
  sum(oi.qty * oi.unit_price)     AS revenue
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN customers  c  ON c.id = o.customer_id
GROUP BY 1, 2;
```

The query now has a name. The code that calls it is four lines instead of forty. It reads beautifully.

And the dashboard still takes forty seconds.

This is the most common non-fix in backend engineering, and it survives because nobody ever checks. So let's check.

---

## A view is a saved question

`CREATE VIEW` stores **no data**. None. It writes one row into the catalog containing, essentially, the text of your query. That's the whole object.

You can watch it store nothing:

```sql
CREATE VIEW daily_revenue AS SELECT ...;

SELECT pg_size_pretty(pg_total_relation_size('daily_revenue'));
--  0 bytes
```

So when you run this:

```sql
SELECT * FROM daily_revenue WHERE region = 'EU';
```

the planner does something that surprises people the first time they see it: it **substitutes the view's definition into your query** and plans the whole thing as if you had typed the forty-line version by hand. Same joins. Same aggregate. Same sequential scan over `order_items`. Same forty seconds.

That gives you two genuinely valuable properties:

1. **The answer is always current.** There is no window in which a view can be wrong, because there is nothing to be wrong — it re-derives the truth on every call.
2. **It costs exactly what the query costs.** No more, no less. No hidden storage, no background job, no failure mode of its own.

Which leads to the sentence worth keeping:

> **A view is a naming convenience, not a performance feature.**

That is not an insult. It is a real and correct tool — when the query is *already* fast and you simply wanted a name, a stable interface, or a place to hang column-level permissions. A view is how you stop copy-pasting the same six-table join into eleven services.

It is never, ever the answer to "the dashboard is slow." You named the query. You didn't fix it.

---

## A materialized view is a saved answer

One extra word changes the object entirely:

```sql
CREATE MATERIALIZED VIEW daily_revenue_mv AS
SELECT
  date_trunc('day', o.created_at) AS day,
  c.region,
  count(*)                        AS orders,
  sum(oi.qty * oi.unit_price)     AS revenue
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN customers  c  ON c.id = o.customer_id
GROUP BY 1, 2;
```

Now the database **runs the query once, right now, and writes the result rows to disk.** What you have afterwards is not a definition — it is a real, physical table that happens to remember where it came from.

```sql
SELECT pg_size_pretty(pg_total_relation_size('daily_revenue_mv'));
--  14 MB
```

And because it is a real table, you can do real table things to it:

```sql
CREATE UNIQUE INDEX ON daily_revenue_mv (day, region);
```

That index is the payoff. The dashboard's forty-second aggregate becomes an index lookup against fourteen megabytes of pre-computed rows:

```sql
SELECT * FROM daily_revenue_mv WHERE region = 'EU' AND day > now() - interval '30 days';
--  9 ms
```

Forty seconds to nine milliseconds, and nothing about your data got faster. You just stopped asking the expensive question.

---

## It is not a cache, and it is not an index

Two comparisons people reach for, both slightly wrong in ways that matter.

**It is not a cache.** A cache — Redis in front of Postgres, say — is a *separate system*. It lives outside the database, it has a TTL and an eviction policy, it can be cold, it can be evicted under memory pressure, and your application code has to know it exists (check cache → miss → query → write back). A materialized view lives **inside** the database, is durable, is backed up with everything else, never evicts itself, and your application just queries a table name. It goes stale on a schedule *you* own, not on a policy the cache manages for you.

**It is not an index.** This distinction is sharper than it sounds:

- An **index** makes the same query faster. The planner still executes your aggregate; the index just helps it find rows.
- A **materialized view** means **not running the query at all**. The work happened earlier, at refresh time.

That is the real category difference: an index optimises execution, a materialized view **moves execution to a different moment in time**. Which is exactly where the cost hides.

---

## The honest cost: everything you gained, you gained by moving work into the past

Here is the trade in one line: **that result was true at REFRESH time, and is wrong from the first write afterwards.**

Not "eventually wrong." Wrong immediately, the instant the next order lands. The only question is how wrong you're willing to be, and for how long.

### Refreshing is not free, and the default one is hostile

```sql
REFRESH MATERIALIZED VIEW daily_revenue_mv;
```

That statement takes an **ACCESS EXCLUSIVE lock** on the view for the entire rebuild. Every reader blocks. If the underlying aggregate takes forty seconds, your dashboard is *unavailable* for forty seconds, every refresh.

This is how a performance optimisation quietly becomes a nightly outage. The 03:00 cron looks harmless right up until the view grows, the rebuild stretches to eleven minutes, and someone in another timezone spends eleven minutes staring at a spinner.

The escape hatch:

```sql
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue_mv;
```

Readers keep reading throughout. But it is not free either:

- It **requires a UNIQUE index** on the view — no unique index, and the statement simply errors out. (This is the real reason the `CREATE UNIQUE INDEX` above is not optional.)
- It is **slower overall**, because it builds the new result in a temp table and then computes and applies the diff rather than swapping wholesale.
- It cannot be used on a view that has never been populated.

### The failure mode that actually hurts

A refresh is a job, and jobs fail. The cron gets disabled during an incident. The role loses a permission. The statement hits a lock timeout. The rebuild starts overlapping with the previous one.

And your dashboard keeps loading in nine milliseconds, serving numbers from last Tuesday, with total confidence and no warning whatsoever.

**A silently failing refresh serves confidently stale numbers.** That is the worst failure mode available to you — far worse than "slow" — because nothing looks broken. If you take one operational rule from this article: *monitor refresh recency, not refresh success.* Alert on `now() - last_successful_refresh > your_budget`, and store that timestamp inside the view itself so a reader can see it:

```sql
CREATE MATERIALIZED VIEW daily_revenue_mv AS
SELECT ..., now() AS computed_at
FROM ...;
```

### And the boring costs

You now have duplicated data to size, back up, and monitor. A 14 MB view is nothing; a 400 GB one is a capacity conversation.

---

## Side by side

<table>
  <thead>
    <tr>
      <th>&nbsp;</th>
      <th>View</th>
      <th>Materialized View</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>What it stores</strong></td>
      <td>The SQL text. Zero rows.</td>
      <td>The result rows, on disk.</td>
    </tr>
    <tr>
      <td><strong>On SELECT</strong></td>
      <td>Re-runs the underlying query</td>
      <td>Reads a table</td>
    </tr>
    <tr>
      <td><strong>Speed</strong></td>
      <td>Exactly the query's cost, forever</td>
      <td>Milliseconds, regardless of query cost</td>
    </tr>
    <tr>
      <td><strong>Freshness</strong></td>
      <td>Always current, by construction</td>
      <td>As of the last refresh</td>
    </tr>
    <tr>
      <td><strong>Can be indexed</strong></td>
      <td>No (it has no rows of its own)</td>
      <td>Yes — and should be</td>
    </tr>
    <tr>
      <td><strong>Storage</strong></td>
      <td>None</td>
      <td>A full copy of the result set</td>
    </tr>
    <tr>
      <td><strong>Ops burden</strong></td>
      <td>None</td>
      <td>Scheduled refresh + recency monitoring</td>
    </tr>
    <tr>
      <td><strong>Fails by</strong></td>
      <td>Being slow, visibly</td>
      <td>Being stale, invisibly</td>
    </tr>
    <tr>
      <td><strong>Reach for it when</strong></td>
      <td>The query is fast and you want a name</td>
      <td>The query is slow and slightly-old is acceptable</td>
    </tr>
  </tbody>
</table>

---

## The same trade-off, one layer up: precomputation in AI serving

If you work on LLM and retrieval systems rather than dashboards, you have already made this exact decision several times — just without the SQL keyword on it. The pattern generalises: **a view is compute-on-read, a materialized view is compute-on-write, and the price of compute-on-write is always freshness.**

- **Embeddings are a materialized view of your documents.** You do not re-embed the corpus per query — you embed once, write the vectors to disk, and index them (HNSW, IVF-Flat). Fast reads, and the identical staleness bug: edit a document without re-embedding it and retrieval confidently returns the old meaning. "Silently failing refresh serves confidently stale numbers" is exactly the RAG pipeline whose re-index job died three weeks ago while search kept answering in 12 ms.
- **A semantic cache is a cache, not a materialized view** — same distinction as above. It sits outside the model, has a TTL and an eviction policy, and is cold on the first hit. A precomputed answer table for your top 500 support questions *is* a materialized view: durable, inside your system, stale on your schedule.
- **A feature store's offline table is a materialized view of your event stream.** That is literally what "training/serving skew" is: your served features were true at materialisation time and wrong from the first event afterwards.
- **Prefix / KV caching for prompts** is the same move again at the token level — pay the compute once for a shared prefix, reuse it, and accept that the reused state is only valid while the prefix is genuinely unchanged.

The engineering lesson transfers cleanly in both directions: whenever you move work earlier to make reads fast, you have not removed the work. You have **converted a latency cost into a correctness window**, and someone has to own that window.

---

## The verdict

You are not choosing between fast and slow. You are choosing between:

> **"always right"** and **"fast, with a staleness budget you say out loud."**

That second phrase is the whole discipline. The moment you can write the budget down as a sentence — *"this report may be up to 10 minutes old"* — three things happen. You can put it in the UI. You can alert on it. And you can hand it to a product owner, who is the person who should have been deciding it all along. It stops being a database question and becomes a product one.

So the one question you must answer before you ship a materialized view is not "how do I refresh it?" It is:

**How stale is this allowed to be, and who is allowed to be surprised by that?**

If nobody can answer it, you do not have a caching problem. You have a requirements problem wearing SQL.

A view saves you typing. A materialized view saves you time, and charges you freshness. Two different products — one of which people buy by accident.

---

▶ **Watch the reel:** [View vs Materialized View in 98 seconds](https://youtube.com/shorts/VQIdisCiOqg) · join the breakdowns on [Telegram](https://t.me/SoftwareEngineerBlog).
