PostgreSQL materialized views offer a way to store the result of a complex query in a table, which can help avoid repetitive calculations and speed up response times greatly.

The screenshot below shows off an almost factor 10 speedup in execution time when using a materialized view instead of a normal query.

Materialized view performance boost

Enough with the motivation, how to create a materialized view?

Creating the materialized view

In this example, we’ll base the materialized view on a view called fat_view.

CREATE MATERIALIZED VIEW fat_mat_view_a AS
SELECT *
FROM fat_view;

Refreshing the materialized view

Caching always involves tradeoffs. Materialized views don’t automatically update, so they can become stale when new data is added.

An explicit refresh is needed:

REFRESH MATERIALIZED VIEW fat_mat_view_a;

During this refresh operation, the materialized view is completely wiped and repopulated, potentially making the app unresponsive. This can be problematic in production environments.

A double-buffered approach can help mitigate this issue by alternating between two materialized views, ensuring one is always up-to-date and ready to use.

Using a double buffered approach for the materialized view

Instead of refreshing the materialized view directly, we can create two materialized views and switch between them — a double-buffered approach. This setup allows one view to remain active while the other refreshes.

The idea is to have two materialized views, fat_mat_view_a and fat_mat_view_b, and one regular view, fat_mat_view, that points to the active materialized view. Here’s a diagram for reference:

graph TD
fat_mat_view --->|active| fat_mat_view_a
fat_mat_view -->|in-active| fat_mat_view_b
fat_mat_view_a -.-> fat_view
fat_mat_view_b -.-> fat_view

active

in-active

fat_mat_view

fat_mat_view_a

fat_mat_view_b

fat_view

So we create an additional materialized view, fat_mat_view_b, and a view, fat_mat_view, that points to the active one.

CREATE MATERIALIZED VIEW fat_mat_view_b AS
SELECT *
FROM fat_view;

-- active view
CREATE VIEW fat_mat_view AS
SELECT *
FROM fat_mat_view_a;

Remember to add relevant indexes to the materialized views, as needed, to optimize query performance:

CREATE INDEX fat_mat_view_a_idx ON public.fat_mat_view_a (vat_number, date);
CREATE INDEX fat_mat_view_b_idx ON public.fat_mat_view_b (vat_number, date);

Refreshing the materialized view and switching active view

The active views are queryable through the pg_views table. We can use this table to determine the active view, refresh the other view, and switch to it once ready.

The following SQL script accomplishes this:

DO
$$
    DECLARE
        active_view TEXT;
    BEGIN
        -- Determine the active view by checking the definition
        SELECT CASE
                   WHEN definition LIKE '%fat_mat_view_a%' THEN 'a'
                   WHEN definition LIKE '%fat_mat_view_b%' THEN 'b'
                   END
        INTO active_view
        FROM pg_views
        WHERE viewname = 'fat_mat_view';

        -- Refresh the inactive view and switch
        IF active_view IS NULL THEN
            RAISE EXCEPTION 'The materialized views does not exist.';
        ELSIF active_view = 'a' THEN
            REFRESH MATERIALIZED VIEW fat_mat_view_b;
            CREATE OR REPLACE VIEW fat_mat_view AS
            SELECT * FROM fat_mat_view_b;
        ELSE
            REFRESH MATERIALIZED VIEW fat_mat_view_a;
            CREATE OR REPLACE VIEW fat_mat_view AS
            SELECT * FROM fat_mat_view_a;
        END IF;
    END
$$;

This refresh process can be set up as a cron job, with the frequency determined based on your application’s tolerance for stale data. And that’s it — a simple but effective way to boost performance where a small amount of staleness is acceptable.