All Products
Search
Document Center

PolarDB:Use a columnstore index to accelerate time series data analytics

Last Updated:Mar 28, 2026

Finance, logistics, and IoT systems generate terabyte-scale time series data — transaction records, trajectory data, monitoring logs — that must be analyzed in real time. Without columnar acceleration, aggregate queries over 100 million rows can take tens of seconds. PolarDB for PostgreSQL's In-Memory Column Index (IMCI) lets you accelerate these analytical queries by adding a columnstore index to an existing table, with no ETL pipelines or schema changes.

How it works

When you create a columnstore index on a base table, PolarDB for PostgreSQL automatically maintains a column-oriented copy of the data alongside the row store. Analytical queries are then routed — by the optimizer or an explicit Hint — to scan the columnstore instead of the row store.

Row store vs. columnstore layout

Storage formatHow data is organizedAggregate query behavior
Row storeEach row stored together: (trade_id, trade_ts, market_id, price, amount, insert_ts)Must read entire rows even when only price is needed
ColumnstoreEach column stored together: all price values, then all amount values, etc.Reads only the relevant columns; higher compression ratio reduces I/O

Three-step flow

  1. Data writing — Applications write time series data (for example, transaction records) to the PolarDB for PostgreSQL cluster.

  2. Columnstore index — Create a columnstore index on the base table. PolarDB for PostgreSQL automatically maintains the columnstore data. Because columns are stored together, compression ratios are higher and aggregate queries read far less data.

  3. Query acceleration — Analytical queries such as candlestick aggregations are directed by the optimizer or a Hint to use the columnstore index. The query engine uses columnar storage and parallel processing to scan and aggregate data.

Advantages

  • No schema or code changes — Create a columnstore index on the base table to accelerate analytical queries transparently. No ETL pipelines or business modifications needed.

  • Built-in time series functions — Natively supports partitioned tables and includes time_bucket, FIRST, and LAST for time series aggregation.

Performance results

Test conditions: 100 million data entries over a 2-day span (~50 million entries per day); candlestick aggregation query covering five metrics (highest, lowest, opening, and closing prices plus total transaction volume); columnstore index degree of parallelism: 8.

Query time (seconds)

ScenarioSecond-level aggregationMinute-level aggregationHourly aggregationDay-level aggregation
Full data (100 million entries)3.410.950.930.91
1-day data (~50 million entries)1.880.820.810.76
12-hour data (~25 million entries)0.890.550.53N/A
1-hour data (~6 million entries)0.410.390.37N/A

Prerequisites

Before you begin, confirm that your cluster meets the following requirements:

  • Supported 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)

    Check the minor engine version in the console or by running SHOW polardb_version;. For more information, see View the minor engine version. If the version does not meet the requirement, upgrade the minor engine version.
  • Primary key — The source table must have a primary key. Include the primary key column when you create the columnstore index.

  • WAL level — The wal_level parameter must be set to logical. This adds the information needed to support logical replication to the write-ahead logging (WAL).

    Set the wal_level parameter in the console. Modifying this parameter restarts the cluster. Plan your business operations accordingly.

Enable the columnstore index

The method 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)

Two methods are available for these versions:

[Recommended] Add a columnstore index read-only nodeUse the pre-installed extension
SetupAdd an IMCI node through the console.No setup required.
Memory allocationColumnstore engine uses all resources exclusively, fully utilizing available memory.Columnstore engine can use only 25% of 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 other.TP and AP workloads share the same node and affect each other.
CostIMCI read-only nodes incur additional charges, billed at the same rate as regular compute nodes.No additional cost.

Add a columnstore index read-only node

The cluster must contain at least one read-only node. Single-node clusters do not support adding a columnstore index read-only node.
Add in the console
  1. Log on to the PolarDB console and select the region where your cluster is located. Open the Add/Remove Node wizard in one of the following ways:

    • On the Clusters page, click Add/Remove Node in the Actions column. image

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

  2. Select Add Read-only IMCI Node and click OK.

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

    1. Click Add an IMCI Node and select the node specifications.

    2. Select a switchover time.

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

    4. Click Buy Now.

    image

  4. After the payment is complete, return to the cluster details page and wait for the node status to change to Running.

    image

Add during purchase

On the PolarDB purchase page, set the IMCI Read-Only Nodes parameter to the number of nodes you need.

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, the columnstore index is deployed as the polar_csi extension. Create the extension in each database where you want to use the columnstore index.

The polar_csi extension is scoped to the database level. To use the columnstore index in multiple databases within a cluster, create the extension in each database separately.
The database account used to install the extension must be a privileged account.

Install from the console

  1. Log on to the PolarDB console. In the left navigation pane, click Clusters, select the region where your cluster is located, and then click the cluster ID.

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

  3. In the upper-right corner, select the target database. In the row for the polar_csi extension, click Install in the Actions column. In the Install Extension dialog box, select the target Database Account and click OK.

    image.png

Install from the command line

Connect to the database cluster and run the following statement in the target database:

CREATE EXTENSION polar_csi;

Run candlestick aggregation queries

This section demonstrates candlestick (OHLCV) aggregation using a financial transaction records scenario. For each time window, the query computes the highest price, lowest price, opening price, closing price, and total transaction volume.

A candlestick chart represents price movement for a financial asset over a fixed time period using five values:

FieldMeaningSQL function
OpenFirst transaction price in the windowFIRST(price ORDER BY trade_ts)
HighHighest transaction price in the windowMAX(price)
LowLowest transaction price in the windowMIN(price)
CloseLast transaction price in the windowLAST(price ORDER BY trade_ts)
VolumeTotal transaction volume in the windowSUM(amount)

Key functions

Three functions are essential for candlestick queries:

  • `time_bucket(bucket_width, ts)` — Groups timestamps by a specified interval (for example, '1 second', '1 minute'). Provided by the Time Series Database.

  • `FIRST(price ORDER BY trade_ts)` — Returns the first value of price ordered by trade_ts, which gives the opening price.

  • `LAST(price ORDER BY trade_ts)` — Returns the last value of price ordered by trade_ts, which gives the closing price.

For the highest and lowest prices, use the standard SQL aggregate functions MAX(price) and MIN(price).

Prepare the data

  1. Create the transaction records table:

    -- Transaction records table
    CREATE TABLE market_trades (
        trade_id   BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,  -- Auto-increment primary key
        trade_ts   TIMESTAMP,                                        -- Transaction timestamp
        market_id  VARCHAR,                                          -- Market ID
        price      DECIMAL,                                          -- Transaction price
        amount     DECIMAL,                                          -- Transaction amount
        insert_ts  TIMESTAMP                                         -- System write timestamp
    );
  2. Insert 100 million simulated transaction entries spanning two days. Peak trading hours (08:00–16:00) account for approximately 40 million entries per day; off-peak hours account for approximately 10 million entries per day.

    INSERT INTO market_trades(trade_ts, market_id, price, amount, insert_ts)
    SELECT
        trade_ts,
        market_id,
        price,
        amount,
        trade_ts + (random() * 500)::INT * INTERVAL '1 millisecond' AS insert_ts
    FROM (
        -- ========================
        -- 1. Day 1 peak: 2025-06-01 8:00 - 16:00, 40 million entries
        -- ========================
        SELECT
            '2025-06-01 08:00:00'::TIMESTAMP +
            (random() * 28800)::INT * INTERVAL '1 second' +  -- 28800 seconds = 8 hours
            (random() * 1000)::INT * INTERVAL '1 millisecond' AS trade_ts,
            CASE WHEN random() < 0.6 THEN 'BTC-USDT' ELSE 'ETH-USDT' END AS market_id,
            CASE WHEN random() < 0.6 THEN 30000 + (random() * 1000) ELSE 2000 + (random() * 100) END AS price,
            random() * 10 + 0.1 AS amount
        FROM generate_series(1, 40000000)
    
        UNION ALL
    
        -- ========================
        -- 2. Day 1 off-peak: 2025-06-01 16:00 - 2025-06-02 08:00, 10 million entries
        -- ========================
        SELECT
            CASE
                WHEN random() < 0.5 THEN
                    -- 16:00 - 24:00
                    '2025-06-01 16:00:00'::TIMESTAMP + (random() * 28800)::INT * INTERVAL '1 second'
                ELSE
                    -- 00:00 - 08:00 (early morning of day 2)
                    '2025-06-02 00:00:00'::TIMESTAMP + (random() * 28800)::INT * INTERVAL '1 second'
            END +
            (random() * 1000)::INT * INTERVAL '1 millisecond' AS trade_ts,
            CASE WHEN random() < 0.6 THEN 'BTC-USDT' ELSE 'ETH-USDT' END AS market_id,
            CASE WHEN random() < 0.6 THEN 30000 + (random() * 1000) ELSE 2000 + (random() * 100) END AS price,
            random() * 10 + 0.1 AS amount
        FROM generate_series(1, 10000000)
    
        UNION ALL
    
        -- ========================
        -- 3. Day 2 peak: 2025-06-02 8:00 - 16:00, 40 million entries
        -- ========================
        SELECT
            '2025-06-02 08:00:00'::TIMESTAMP +
            (random() * 28800)::INT * INTERVAL '1 second' +
            (random() * 1000)::INT * INTERVAL '1 millisecond' AS trade_ts,
            CASE WHEN random() < 0.6 THEN 'BTC-USDT' ELSE 'ETH-USDT' END AS market_id,
            CASE WHEN random() < 0.6 THEN 30000 + (random() * 1000) ELSE 2000 + (random() * 100) END AS price,
            random() * 10 + 0.1 AS amount
        FROM generate_series(1, 40000000)
    
        UNION ALL
    
        -- ========================
        -- 4. Day 2 off-peak: 2025-06-02 16:00 - 2025-06-03 08:00, 10 million entries
        -- ========================
        SELECT
            CASE
                WHEN random() < 0.5 THEN
                    -- 16:00 - 24:00
                    '2025-06-02 16:00:00'::TIMESTAMP + (random() * 28800)::INT * INTERVAL '1 second'
                ELSE
                    -- 00:00 - 08:00 (early morning of day 3)
                    '2025-06-03 00:00:00'::TIMESTAMP + (random() * 28800)::INT * INTERVAL '1 second'
            END +
            (random() * 1000)::INT * INTERVAL '1 millisecond' AS trade_ts,
            CASE WHEN random() < 0.6 THEN 'BTC-USDT' ELSE 'ETH-USDT' END AS market_id,
            CASE WHEN random() < 0.6 THEN 30000 + (random() * 1000) ELSE 2000 + (random() * 100) END AS price,
            random() * 10 + 0.1 AS amount
        FROM generate_series(1, 10000000)
    ) AS data;
  3. Create a columnstore index on the transaction records table:

    CREATE INDEX idx_csi_market_trades ON market_trades USING CSI;

Query examples

All examples use the /*+ SET (polar_csi.enable_query on) */ hint to force the query to use the columnstore index execution plan. In some cases, the optimizer may incorrectly estimate that the row store is more efficient — this hint ensures the columnstore path is used.

Second-level candlestick aggregation

-- Second-level candlestick aggregation
/*+ SET (polar_csi.enable_query on) */
SELECT
    time_bucket('1 second', trade_ts) AS candle_ts,   -- Data within 1 second
    market_id,
    MIN(price) AS low,                                -- Lowest price within 1 second
    MAX(price) AS high,                               -- Highest price within 1 second
    FIRST(price ORDER BY trade_ts) AS open,           -- Opening price within 1 second
    LAST(price ORDER BY trade_ts) AS close,           -- Closing price within 1 second
    SUM(amount) AS vol                                -- Total transaction volume within 1 second
FROM market_trades
WHERE trade_ts >= '2025-06-01 00:00:00' AND trade_ts <= '2025-06-02 00:00:00'
GROUP BY candle_ts, market_id
ORDER BY candle_ts, market_id;

Minute-level candlestick aggregation

-- Minute-level candlestick aggregation
/*+ SET (polar_csi.enable_query on) */
SELECT
    time_bucket('1 minute', trade_ts) AS candle_ts,   -- Data within 1 minute
    market_id,
    MIN(price) AS low,                                -- Lowest price within 1 minute
    MAX(price) AS high,                               -- Highest price within 1 minute
    FIRST(price ORDER BY trade_ts) AS open,           -- Opening price within 1 minute
    LAST(price ORDER BY trade_ts) AS close,           -- Closing price within 1 minute
    SUM(amount) AS vol                                -- Total transaction volume within 1 minute
FROM market_trades
WHERE trade_ts >= '2025-06-01 00:00:00' AND trade_ts <= '2025-06-02 00:00:00'
GROUP BY candle_ts, market_id
ORDER BY candle_ts, market_id;

Hour-level candlestick aggregation

-- Hour-level candlestick aggregation
/*+ SET (polar_csi.enable_query on) */
SELECT
    time_bucket('1 hour', trade_ts) AS candle_ts,     -- Data within 1 hour
    market_id,
    MIN(price) AS low,                                -- Lowest price within 1 hour
    MAX(price) AS high,                               -- Highest price within 1 hour
    FIRST(price ORDER BY trade_ts) AS open,           -- Opening price within 1 hour
    LAST(price ORDER BY trade_ts) AS close,           -- Closing price within 1 hour
    SUM(amount) AS vol                                -- Total transaction volume within 1 hour
FROM market_trades
WHERE trade_ts >= '2025-06-01 00:00:00' AND trade_ts <= '2025-06-02 00:00:00'
GROUP BY candle_ts, market_id
ORDER BY candle_ts, market_id;

Day-level candlestick aggregation

-- Day-level candlestick aggregation
/*+ SET (polar_csi.enable_query on) */
SELECT
    time_bucket('1 day', trade_ts) AS candle_ts,      -- Data within 1 day
    market_id,
    MIN(price) AS low,                                -- Lowest price within 1 day
    MAX(price) AS high,                               -- Highest price within 1 day
    FIRST(price ORDER BY trade_ts) AS open,           -- Opening price within 1 day
    LAST(price ORDER BY trade_ts) AS close,           -- Closing price within 1 day
    SUM(amount) AS vol                                -- Total transaction volume within 1 day
FROM market_trades
WHERE trade_ts >= '2025-06-01 00:00:00' AND trade_ts <= '2025-06-02 00:00:00'
GROUP BY candle_ts, market_id
ORDER BY candle_ts, market_id;