All Products
Search
Document Center

PolarDB:Use IMCI to accelerate materialized view refreshes

Last Updated:Mar 28, 2026

Refreshing a PostgreSQL materialized view over billions of records can take minutes to hours, making it impractical for business intelligence (BI) analysis and reporting that require fresh data. The In-Memory Column Index (IMCI) feature in PolarDB for PostgreSQL dramatically reduces refresh time by pushing the underlying query through a column store engine instead of a row-store scan.

This guide covers how to enable IMCI, create a materialized view that uses it, and automate refreshes with pg_cron.

When to use this approach

Use IMCI-accelerated refreshes when:

  • Your base tables contain billions of records

  • The materialized view query involves complex aggregations or multi-table joins

  • You need data refreshed on a schedule (every few minutes to hours) rather than in real time

For small tables or simple queries, a standard REFRESH MATERIALIZED VIEW statement is sufficient.

How it works

IMCI creates a columnstore index alongside a row-store table and keeps both in sync automatically. When the database executes a query that benefits from column-oriented access—such as aggregations or joins over large datasets—it routes computation through the column store engine.

Creating an IMCI on the base tables of a materialized view accelerates both the initial view creation and subsequent refreshes.

image

Prerequisites

Before you begin, ensure that you have:

  • A PolarDB for PostgreSQL cluster running one of the following versions:

    • PostgreSQL 16, minor engine version 2.0.16.8.3.0 or later

    • PostgreSQL 14, minor engine version 2.0.14.10.20.0 or later

  • A source table with a primary key (required for IMCI; the primary key column must be included in the columnstore index)

  • The wal_level parameter set to logical

To check your minor engine version, run SHOW polardb_version; or view it in the console. To upgrade, see Version management.
Set wal_level in the console. See Set the wal_level parameter. The cluster restarts after this change—plan accordingly.

Usage notes

  • Each table supports only one columnstore index.

  • Columnstore indexes cannot be modified. To add columns, rebuild the index.

Set up the environment

Step 1: Enable IMCI

The method for enabling IMCI depends on your minor engine version.

PostgreSQL 16 (2.0.16.9.8.0 or later) or PostgreSQL 14 (2.0.14.17.35.0 or later)

These versions support two methods. Choose based on your workload isolation and cost requirements:

PolarDB purchase page[Recommended] Add a columnstore index read-only nodeUse the pre-installed columnstore index extension
SetupAdd a node from the consoleNo setup required
MemoryThe column store engine uses all available memory on the dedicated nodeThe column store engine is limited to 25% of node memory. The remaining memory is allocated to the row store engine.
Workload isolationTP and AP workloads run on separate nodes and do not affect each otherTP and AP workloads share a node and may affect each other
CostCharged at the same rate as a regular compute nodeNo additional cost

Add a columnstore index read-only node

The cluster must already have at least one read-only node. Single-node clusters do not support adding a columnstore index read-only node.

Option A: Add from the console

  1. Log on to the PolarDB console and select the region where your cluster is located. Open the Add/Remove Nodes wizard using one of these methods:

    • On the Basic Information page of your cluster, click Add/Remove Node in the Database Nodes section.

    image

    image

  2. Select Add Columnstore Index Read-only Node and click OK.

  3. On the cluster upgrade/downgrade page, configure the node and complete payment:

    1. Click +Add a Columnstore Index Read-only Node and select node specifications.

    2. Select a switchover time.

    3. (Optional) Review the Terms of Service and Service Level Agreement.

    4. Click Buy Now.

    image

  4. Return to the cluster details page and wait for the node status to change to Running.

    image

Option B: Add during purchase

On the PolarDB purchase page, set the Columnstore Index Read-only Nodes count under the Number Of Nodes parameter.

image

PostgreSQL 16 (2.0.16.8.3.0 to 2.0.16.9.8.0) or PostgreSQL 14 (2.0.14.10.20.0 to 2.0.14.17.35.0)

For these versions, IMCI is packaged as the polar_csi extension. Install it in each database where you want to use it.

The polar_csi extension is scoped to the database level. Install it separately in each database that needs it.
The database account used to install the extension must be a privileged account.

Option A: Install from the console

  1. Log on to the PolarDB console. In the left navigation pane, click Clusters, select your cluster's region, and click the cluster ID.

  2. In the left navigation pane, choose Configuration and Management > Extension Management. On the Manage Extensions tab, select Uninstalled Extensions.

  3. In the upper-right corner, select the target database. Find polar_csi and click Install. In the dialog box, select the target Database Account and click OK.

    image.png

Option B: Install from the command line

Connect to the database cluster and run:

CREATE EXTENSION polar_csi;

Step 2: Install supporting extensions

Install the pg_hint_plan extension, which lets you control the query optimizer using hints in SQL comments:

CREATE EXTENSION pg_hint_plan;

Install the pg_cron extension in the postgres system database for scheduled refresh tasks:

\c postgres;
CREATE EXTENSION pg_cron;

Step 3: Prepare the base tables

Switch to your business database and create the base tables with columnstore indexes.

This example uses a testdb database with customers and orders tables:

\c testdb;

-- Create the customers table with a columnstore index
CREATE TABLE customers (
    customer_id SERIAL PRIMARY KEY,
    customer_name VARCHAR(100),
    email VARCHAR(100)
);
CREATE INDEX idx_customers_csi ON customers USING csi;

-- Create the orders table with a columnstore index
CREATE TABLE orders (
    order_id SERIAL PRIMARY KEY,
    order_date DATE,
    amount DECIMAL(10, 2),
    customer_id INT REFERENCES customers(customer_id)
);
CREATE INDEX idx_orders_csi ON orders USING csi;

-- Insert sample data
INSERT INTO customers (customer_name, email) VALUES
('Alice', 'alice@example.com'),
('Bob', 'bob@example.com'),
('Charlie', 'charlie@example.com');

INSERT INTO orders (order_date, amount, customer_id) VALUES
('2025-06-01', 200.00, 1),
('2025-06-02', 150.00, 2),
('2025-06-03', 300.00, 1),
('2025-06-04', 100.00, 3);

Create the materialized view

Use query hints to force the optimizer to route the view query through the columnstore index:

/*+ SET(polar_csi.enable_query on) SET(polar_csi.cost_threshold 0) SET(polar_csi.exec_parallel 6) SET(polar_csi.memory_limit 10240) */
CREATE MATERIALIZED VIEW mv_customer_orders AS
SELECT
    c.customer_name AS customer_name,
    o.order_date AS order_date,
    o.amount AS amount
FROM
    orders o
JOIN
    customers c ON o.customer_id = c.customer_id;

Hint parameters

ParameterDescription
polar_csi.enable_query onAllows the query to use the columnstore index
polar_csi.cost_threshold 0Forces the optimizer to choose the columnstore index by setting the cost threshold to 0
polar_csi.exec_parallel 6Sets the degree of parallelism for column store computation. We recommend that this value does not exceed the number of CPU cores on the node.
polar_csi.memory_limit 10240Sets the memory available for computation, in MB

Refresh the materialized view

About the refresh strategy

The refresh function described below uses a full-rebuild strategy: it creates a new materialized view, copies indexes and permissions from the old one, drops the old view, and renames the new one. This ensures IMCI hints are applied consistently on every refresh.

Create the refresh function

This function is provided as a reference implementation. Test it thoroughly in a staging environment before using it in production.
-- Parameters:
--   view_name:   Name of the materialized view to refresh
--   schema_name: Schema containing the materialized view (defaults to current_schema)
--   new_owner:   Owner to assign to the newly created view (optional)
CREATE OR REPLACE FUNCTION refresh_materialized_view_safely_using_csi(
    view_name TEXT,
    schema_name TEXT DEFAULT NULL,
    new_owner TEXT DEFAULT NULL
)
RETURNS BOOL
LANGUAGE plpgsql
AS $$
DECLARE
    view_definition TEXT;
    new_view_name TEXT;
    old_view_name TEXT;
    index_record RECORD;
    index_creation_sql TEXT;
    explain_result TEXT;
    target_schema TEXT;
    qualified_old_name TEXT;
    qualified_new_name TEXT;
    current_owner TEXT;
    grant_record RECORD;
BEGIN
    -- Determine the target schema (use the input parameter or the current schema).
    IF schema_name IS NULL THEN
        target_schema := current_schema();
    ELSE
        target_schema := schema_name;
    END IF;

    -- Construct the fully qualified table name.
    qualified_old_name := format('%I.%I', target_schema, view_name);
    qualified_new_name := format('%I.%I', target_schema, view_name || '_new');

    RAISE NOTICE 'Operating in schema: %', target_schema;

    -- Verify that the materialized view exists.
    IF NOT EXISTS (
        SELECT 1 FROM pg_matviews
        WHERE matviewname = view_name
        AND schemaname = target_schema
    ) THEN
        RAISE EXCEPTION 'Materialized view "%" does not exist in schema "%"', view_name, target_schema;
    END IF;

    -- Get the definition and current owner of the materialized view.
    SELECT m.definition, p.rolname INTO view_definition, current_owner
    FROM pg_matviews m
    JOIN pg_class c ON m.matviewname = c.relname AND m.schemaname = target_schema
    JOIN pg_roles p ON c.relowner = p.oid
    WHERE m.matviewname = view_name
    AND m.schemaname = target_schema;

    IF view_definition IS NULL THEN
        RAISE EXCEPTION 'Failed to retrieve definition for materialized view "%"', view_name;
    END IF;

    -- Set the names for the new and old views.
    old_view_name := view_name;
    new_view_name := view_name || '_new';

    -- Performance parameters for IMCI.
    SET LOCAL polar_csi.cost_threshold = 0;

    -- Print the query plan.
    RAISE NOTICE 'Query plan for materialized view refresh:';
    FOR explain_result IN EXECUTE format('/*+ SET(polar_csi.enable_query on) */ EXPLAIN CREATE MATERIALIZED VIEW %s AS %s', qualified_new_name, view_definition) LOOP
        RAISE NOTICE '%', explain_result;
    END LOOP;

    BEGIN
        -- Create the new materialized view.
        EXECUTE format('/*+ SET(polar_csi.enable_query on) */ CREATE MATERIALIZED VIEW %s AS %s', qualified_new_name, view_definition);

        -- If a new owner is specified, set the owner.
        IF new_owner IS NOT NULL THEN
            -- Verify that the user exists.
            IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = new_owner) THEN
                RAISE EXCEPTION 'Role "%" does not exist', new_owner;
            END IF;

            EXECUTE format('ALTER MATERIALIZED VIEW %s OWNER TO %I', qualified_new_name, new_owner);
            RAISE NOTICE 'Changed owner from "%" to "%"', current_owner, new_owner;
        END IF;

        -- Copy all indexes from the old view to the new view.
        FOR index_record IN
            SELECT indexname, indexdef
            FROM pg_indexes
            WHERE tablename = old_view_name
            AND schemaname = target_schema
        LOOP
            -- Replace the old view name with the new view name.
            index_creation_sql := regexp_replace(
                index_record.indexdef,
                ' ON ' || target_schema || '.' || old_view_name || ' ',
                ' ON ' || target_schema || '.' || new_view_name || ' ',
                'i'
            );

            -- Handle the special case of UNIQUE indexes.
            index_creation_sql := regexp_replace(
                index_creation_sql,
                'INDEX ' || index_record.indexname || ' ON',
                'INDEX ' || index_record.indexname || '_new ON',
                'i'
            );

            RAISE NOTICE 'Creating index: %', index_creation_sql;
            EXECUTE index_creation_sql;
        END LOOP;

        -- Copy permissions from the old view.
        RAISE NOTICE 'Restoring permissions to new view %.%', target_schema, new_view_name;
        FOR grant_record IN
        SELECT
            (acl).grantee::regrole::text AS grantee,
            (acl).privilege_type
        FROM
            pg_class c
        JOIN pg_namespace n ON c.relnamespace = n.oid
        CROSS JOIN aclexplode(c.relacl) AS acl
        WHERE
            n.nspname = target_schema
        AND c.relname = old_view_name
        LOOP
            CONTINUE WHEN grant_record.grantee IS NULL;

            EXECUTE format(
                'GRANT %s ON %I.%I TO %s',
                grant_record.privilege_type,
                target_schema,
                new_view_name,
                quote_ident(grant_record.grantee)
            );
        END LOOP;

        -- Drop the old materialized view.
        EXECUTE format('DROP MATERIALIZED VIEW %s', qualified_old_name);

        -- Rename the new materialized view to the original name.
        EXECUTE format('ALTER MATERIALIZED VIEW %s RENAME TO %I', qualified_new_name, old_view_name);

        -- Rename the indexes (remove the _new suffix).
        FOR index_record IN
            SELECT indexname
            FROM pg_indexes
            WHERE tablename = old_view_name
            AND schemaname = target_schema
        LOOP
            IF position('_new' in index_record.indexname) > 0 THEN
                EXECUTE format(
                    'ALTER INDEX %I.%I RENAME TO %I',
                    target_schema,
                    index_record.indexname,
                    replace(index_record.indexname, '_new', '')
                );
            END IF;
        END LOOP;

        RETURN TRUE;
    EXCEPTION
        WHEN OTHERS THEN
            RAISE EXCEPTION 'Failed to refresh materialized view: %', SQLERRM;
            RETURN FALSE;
    END;
END;
$$;

Function parameters

ParameterDefaultDescription
view_nameName of the materialized view
schema_namecurrent_schemaSchema where the materialized view resides
new_ownerOwner to assign to the rebuilt view. Specify this when a privileged account runs the refresh so that regular users retain read access

Refresh manually

Call the function directly when needed. Replace mv_customer_orders with your materialized view name:

SELECT refresh_materialized_view_safely_using_csi('mv_customer_orders');

Schedule automatic refreshes with pg_cron

Scheduled tasks must be created in the postgres system database using a privileged account.
Specify new_owner when a privileged account runs the scheduled refresh, so that regular users can still read the rebuilt view.
Set the task interval longer than the actual refresh duration. Because a refresh writes data, it is much slower than a SELECT. If a new task starts before the previous one finishes, tasks stack up and can degrade performance.

Create a scheduled task

Switch to the postgres database, then create the task. Replace the placeholders with your actual values:

PlaceholderDescription
<mv_name>Name of your materialized view
<database_name>Name of your business database
<schema_name>Name of your schema
<user_name>Owner to assign to the rebuilt view
\c postgres;

SELECT cron.schedule_in_database(
    'refresh_mv_customer_orders',  -- Task name (customizable)
    '*/5 * * * *',                 -- Cron expression (every 5 minutes in this example)
    $$SELECT refresh_materialized_view_safely_using_csi('<mv_name>', '<schema_name>', '<user_name>')$$,
    '<database_name>'
);

Example

SELECT cron.schedule_in_database(
    'refresh_mv_customer_orders',
    '*/5 * * * *',
    $$SELECT refresh_materialized_view_safely_using_csi('mv_customer_orders', 'public', 'polarpg')$$,
    'testdb'
);

View configured tasks

SELECT * FROM cron.job;

Expected output:

jobid  |  schedule   |                                           command                                            | nodename | nodeport | database | username | active |          jobname
-------+-------------+----------------------------------------------------------------------------------------------+----------+----------+----------+----------+--------+----------------------------
     1 | */5 * * * * | SELECT refresh_materialized_view_safely_using_csi('mv_customer_orders', 'public', 'polarpg') | /data/.  |     3000 | testdb   | polarpg  | t      | refresh_mv_customer_orders
(1 row)

View task execution details

SELECT * FROM cron.job_run_details;

Expected output:

 jobid | runid | job_pid | database | username |                                           command                                            |  status   | return_message |          start_time           |           end_time
-------+-------+---------+----------+----------+----------------------------------------------------------------------------------------------+-----------+----------------+-------------------------------+-------------------------------
     1 |     1 |   76537 | testdb   | polarpg  | SELECT refresh_materialized_view_safely_using_csi('mv_customer_orders', 'public', 'polarpg') | succeeded | 1 row          | 2025-08-27 08:35:00.007231+00 | 2025-08-27 08:35:00.024946+00
(1 rows)

Delete a scheduled task

SELECT cron.unschedule('refresh_my_materialized_view');

Query the materialized view

Before running the query, switch to your business database. Replace mv_customer_orders with your materialized view name:

SELECT customer_name, COUNT(*) FROM mv_customer_orders GROUP BY customer_name;