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 format | How data is organized | Aggregate query behavior |
|---|---|---|
| Row store | Each row stored together: (trade_id, trade_ts, market_id, price, amount, insert_ts) | Must read entire rows even when only price is needed |
| Columnstore | Each 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
Data writing — Applications write time series data (for example, transaction records) to the PolarDB for PostgreSQL cluster.
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.
Query acceleration — Analytical queries such as candlestick aggregations are directed by the optimizer or a
Hintto 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, andLASTfor 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)
| Scenario | Second-level aggregation | Minute-level aggregation | Hourly aggregation | Day-level aggregation |
|---|---|---|---|---|
| Full data (100 million entries) | 3.41 | 0.95 | 0.93 | 0.91 |
| 1-day data (~50 million entries) | 1.88 | 0.82 | 0.81 | 0.76 |
| 12-hour data (~25 million entries) | 0.89 | 0.55 | 0.53 | N/A |
| 1-hour data (~6 million entries) | 0.41 | 0.39 | 0.37 | N/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_levelparameter must be set tological. This adds the information needed to support logical replication to the write-ahead logging (WAL).Set the
wal_levelparameter 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 node | Use the pre-installed extension | |
|---|---|---|
| Setup | Add an IMCI node through the console. | No setup required. |
| Memory allocation | Columnstore 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 isolation | TP 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. |
| Cost | IMCI 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
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.

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

Select Add Read-only IMCI Node and click OK.
On the cluster upgrade/downgrade page, add the node and complete the payment:
Click Add an IMCI Node and select the node specifications.
Select a switchover time.
(Optional) Review the Product Terms of Service and Service Level Agreement.
Click Buy Now.

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

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

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
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.
In the left navigation pane, choose Settings and Management > Extension Management. On the Extension Management tab, select Uninstalled Extensions.
In the upper-right corner, select the target database. In the row for the
polar_csiextension, click Install in the Actions column. In the Install Extension dialog box, select the target Database Account and click OK.
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:
| Field | Meaning | SQL function |
|---|---|---|
| Open | First transaction price in the window | FIRST(price ORDER BY trade_ts) |
| High | Highest transaction price in the window | MAX(price) |
| Low | Lowest transaction price in the window | MIN(price) |
| Close | Last transaction price in the window | LAST(price ORDER BY trade_ts) |
| Volume | Total transaction volume in the window | SUM(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
priceordered bytrade_ts, which gives the opening price.`LAST(price ORDER BY trade_ts)` — Returns the last value of
priceordered bytrade_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
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 );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;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;