KDB knowledge

Partitions, SORT BY and DEDUP KEY

How PARTITION BY, SORT BY and DEDUP KEY of a time-series table change where rows go, their order, and what happens to duplicates; DROP PARTITION deletes old data fast.

A time-series table (CREATE TABLE ... TIMESERIES(time)) has three extra options. This page shows what they do. The full CREATE TABLE and ALTER TABLE grammar is in create-table and alter-table.

OptionWhat it decidesDefault
PARTITION BYwhich file a row goes into: one file per day, month or year, and with SYMBOL(col) one per symbol tooDAY
SORT BYthe order of rows inside each filethe first SYMBOL column, then the time column
DEDUP KEYwhen two rows are the same row (a duplicate)none: every row is kept

PARTITION BY and SORT BY cannot be changed after the table is made. Choose them when you create the table. The DEDUP KEY can be set or taken off later with ALTER TABLE ... DEDUP ENABLE, which rewrites the rows.

Example: The sample market tables and their partitions (sample data: sample)

SHOW TABLES;

Result:

name (STRING)kind (STRING)partition (STRING)columns (INT)rows (LONG)
brokersrelationalNONE25
live_datatimeseriesDAY1212
ochl_daytimeseriesYEAR757
ochl_minutestimeseriesMONTH7956
securitiesrelationalNONE35
trade_logtimeseriesDAY832

(6 rows)

PARTITION BY

Syntax:

CREATE TABLE name (...) TIMESERIES(time_column)
    PARTITION BY DAY | MONTH | YEAR | NONE

A partition is one part of the table on disk. KDB puts each row into the partition of its time: with PARTITION BY MONTH, all rows of September 2026 go into the partition 2026-09.

PARTITION BYPartition namesGood for
DAY (default)'2026-09-10'trades, live price snapshots (many rows per day)
MONTH'2026-09'one-minute candles
YEAR'2026'daily candles, yearly reports
NONE(one part only)small tables

Example: A condition on time skips whole partitions (sample data: sample)

EXPLAIN SELECT symbol, close FROM ochl_day WHERE time IN '2025-12';
EXPLAIN SELECT symbol, close FROM ochl_day WHERE close > 500;

Result:

EXPLAIN SELECT symbol, close FROM ochl_day WHERE time IN '2025-12'

plan (STRING)
scan table ochl_day (1 of 2 partitions, about 57 rows)
build 3 of 7 stored column(s) per row: time, symbol, close
filter while scanning: 1 condition(s)
engine: column

(4 rows)

EXPLAIN SELECT symbol, close FROM ochl_day WHERE close > 500

plan (STRING)
scan table ochl_day (2 of 2 partitions, about 57 rows)
build 3 of 7 stored column(s) per row: time, symbol, close
filter while scanning: 1 condition(s)
engine: column

(4 rows)

Example: Month partitions: rows of three months make three partitions (sample data: sample)

CREATE TABLE prices (time TIMESTAMP, symbol SYMBOL, close DOUBLE)
  TIMESERIES(time) PARTITION BY MONTH;
INSERT INTO prices VALUES
  ('2026-07-31T11:00:00+05:45', 'CITY', 511.0),
  ('2026-08-30T11:00:00+05:45', 'CITY', 507.3),
  ('2026-09-10T11:00:00+05:45', 'CITY', 504.6);
EXPLAIN SELECT close FROM prices WHERE time IN '2026-09';

Result:

CREATE TABLE prices (

table 'prices' created

INSERT INTO prices VALUES ('2026-07-31T11:00:00+05:45', 'CITY', 511.0), ('2026-08-30T11:00:00+05:45'

3 row(s) inserted

EXPLAIN SELECT close FROM prices WHERE time IN '2026-09'

plan (STRING)
scan table prices (1 of 3 partitions, about 3 rows)
build 2 of 3 stored column(s) per row: time, close
filter while scanning: 1 condition(s)
engine: column

(4 rows)

Example: Early morning in Nepal is the day before in UTC: the error lists partition 2026-09-09 (sample data: sample; shows an error)

Setup:

CREATE TABLE ticks (time TIMESTAMP, symbol SYMBOL, price DOUBLE) TIMESERIES(time) PARTITION BY DAY;
INSERT INTO ticks VALUES ('2026-09-10T02:00:00+05:45', 'CITY', 1), ('2026-09-10T11:00:00+05:45', 'CITY', 2);
ALTER TABLE ticks DROP PARTITION '2026-09-11';

Result:

Error: table 'ticks' has no partition '2026-09-11'; partitions: 2026-09-09, 2026-09-10

Example: Partitions are only for time-series tables (sample data: sample; shows an error)

CREATE TABLE app_notes (id INT PRIMARY KEY, text STRING) PARTITION BY DAY;

Result:

Error: line 1, column 58: PARTITION BY is only for time-series tables; expected TIMESERIES(<TIMESTAMP or DATE column>) on table 'app_notes'

PARTITION BY time, SYMBOL(col)

Syntax:

CREATE TABLE name (...) TIMESERIES(time_column)
    PARTITION BY YEAR | MONTH | NONE, SYMBOL(column)
    SORT BY (column, ...) [DEDUP KEY (column, ..., time_column)]
PARTITION BY ..., KEY(column)        -- the same, for a column that is not a market symbol

A second level of partitions: each time bucket is split again into one file per value of the column. A query for one symbol then opens only that symbol's files.

RuleWhy
the time part is YEAR, MONTH or NONE; DAY is refusedone small file per symbol per day makes thousands of files a week
the column is a stored SYMBOL or STRING columnits value names the file
it is the first SORT BY columnthen reading the files one after the other keeps the SORT BY order
a DEDUP KEY must include ita duplicate is looked for in one partition only
a value is at most 64 bytes, with no 0 bytethe value is part of a file name
at most 4,096 values with NONE, 100,000 partitions with YEAR or MONTHevery value is a file of its own

Example: Daily candles in one file per year and symbol (sample data: sample)

Setup:

CREATE TABLE candles (time TIMESTAMP ROUND 1d, symbol SYMBOL, close DOUBLE)
  TIMESERIES(time) PARTITION BY YEAR, SYMBOL(symbol) SORT BY (symbol, time) DEDUP KEY (symbol, time);
INSERT INTO candles VALUES
  ('2025-09-10', 'CITY', 500.0), ('2026-09-10', 'CITY', 504.6),
  ('2026-09-10', 'COAST', 376.1), ('2026-09-10', 'VOLT', 191.2);
SHOW TABLES;
EXPLAIN SELECT time, close FROM candles WHERE symbol = 'COAST';
ALTER TABLE candles DROP PARTITION '2025';
SELECT time, symbol, close FROM candles ORDER BY symbol;

Result:

SHOW TABLES

name (STRING)kind (STRING)partition (STRING)columns (INT)rows (LONG)
brokersrelationalNONE25
candlestimeseriesYEAR, SYMBOL(symbol)34
live_datatimeseriesDAY1212
ochl_daytimeseriesYEAR757
ochl_minutestimeseriesMONTH7956
securitiesrelationalNONE35
trade_logtimeseriesDAY832

(7 rows)

EXPLAIN SELECT time, close FROM candles WHERE symbol = 'COAST'

plan (STRING)
scan table candles (1 of 4 partitions, about 4 rows)
read only 1 key range(s) of each partition
filter while scanning: 1 condition(s)
engine: column

(4 rows)

ALTER TABLE candles DROP PARTITION '2025'

1 partition(s) of 'candles' dropped

SELECT time, symbol, close FROM candles ORDER BY symbol

time (TIMESTAMP)symbol (SYMBOL)close (DOUBLE)
2026-09-10T00:00:00.000ZCITY504.6
2026-09-10T00:00:00.000ZCOAST376.1
2026-09-10T00:00:00.000ZVOLT191.2

(3 rows)

Example: News split by source: KEY(col) and PARTITION BY NONE (sample data: sample)

Setup:

CREATE TABLE news (time TIMESTAMP, source STRING, title STRING)
  TIMESERIES(time) PARTITION BY NONE, KEY(source) SORT BY (source, time);
INSERT INTO news VALUES
  ('2026-09-10T11:00:00+05:45', 'wire one', 'City Bank profit rises 18%'),
  ('2026-09-11T09:30:00+05:45', 'market desk', 'Index closes higher');
EXPLAIN SELECT title FROM news WHERE source = 'market desk';
SHOW CREATE TABLE news;

Result:

EXPLAIN SELECT title FROM news WHERE source = 'market desk'

plan (STRING)
scan table news (1 of 2 partitions, about 2 rows)
read only 1 key range(s) of each partition
filter while scanning: 1 condition(s)
engine: column

(4 rows)

SHOW CREATE TABLE news

create_sql (STRING)
CREATE TABLE news ( time TIMESTAMP, source STRING, title STRING ) TIMESERIES(time) PARTITION BY NONE, SYMBOL(source) SORT BY (source, time)

(1 row)

Example: DAY is refused with SYMBOL(col) (sample data: sample; shows an error)

CREATE TABLE ticks (time TIMESTAMP, symbol SYMBOL, price DOUBLE)
  TIMESERIES(time) PARTITION BY DAY, SYMBOL(symbol);

Result:

Error: PARTITION BY ..., SYMBOL(column) on table 'ticks': the time part must be YEAR, MONTH or NONE (as in PARTITION BY YEAR, SYMBOL(symbol))

Example: The column must be the first SORT BY column (sample data: sample; shows an error)

CREATE TABLE candles (time TIMESTAMP, symbol SYMBOL, close DOUBLE)
  TIMESERIES(time) PARTITION BY MONTH, SYMBOL(symbol) SORT BY (time);

Result:

Error: PARTITION BY ..., SYMBOL(column) on table 'candles': 'symbol' must be the first SORT BY column (as in SORT BY (symbol, time))

Example: The DEDUP KEY must hold the column (sample data: sample; shows an error)

CREATE TABLE candles (time TIMESTAMP, symbol SYMBOL, close DOUBLE)
  TIMESERIES(time) PARTITION BY MONTH, SYMBOL(symbol) DEDUP KEY (time);

Result:

Error: PARTITION BY ..., SYMBOL(column) on table 'candles': the DEDUP KEY must include 'symbol'

SORT BY

Syntax:

CREATE TABLE name (...) TIMESERIES(time_column)
    [PARTITION BY ...]
    SORT BY (column [, column ...])

Sets the order of rows inside each partition. Rows are kept sorted by these columns, whatever order you insert them in.

Example: Default SORT BY (symbol, time): rows come grouped by symbol (sample data: sample)

Setup:

CREATE TABLE trades (time TIMESTAMP, symbol SYMBOL, broker INT, qty LONG) TIMESERIES(time) PARTITION BY DAY;
INSERT INTO trades VALUES
  ('2026-09-10T11:02:00+05:45', 'COAST', 58, 10),
  ('2026-09-10T11:00:00+05:45', 'CITY', 45, 20),
  ('2026-09-10T11:01:00+05:45', 'COAST', 34, 30),
  ('2026-09-10T11:00:00+05:45', 'VOLT', 1, 40);
SELECT * FROM trades;
EXPLAIN SELECT * FROM trades WHERE symbol = 'COAST';

Result:

SELECT * FROM trades

time (TIMESTAMP)symbol (SYMBOL)broker (INT)qty (LONG)
2026-09-10T05:15:00.000ZCITY4520
2026-09-10T05:16:00.000ZCOAST3430
2026-09-10T05:17:00.000ZCOAST5810
2026-09-10T05:15:00.000ZVOLT140

(4 rows)

EXPLAIN SELECT * FROM trades WHERE symbol = 'COAST'

plan (STRING)
scan table trades (1 of 1 partitions, about 4 rows)
read only 1 key range(s) of each partition
filter while scanning: 1 condition(s)
engine: column

(4 rows)

Example: SORT BY (time): rows come in time order, but a symbol filter reads everything (sample data: sample)

Setup:

CREATE TABLE trades (time TIMESTAMP, symbol SYMBOL, broker INT, qty LONG)
  TIMESERIES(time) PARTITION BY DAY SORT BY (time);
INSERT INTO trades VALUES
  ('2026-09-10T11:02:00+05:45', 'COAST', 58, 10),
  ('2026-09-10T11:00:00+05:45', 'CITY', 45, 20),
  ('2026-09-10T11:01:00+05:45', 'COAST', 34, 30),
  ('2026-09-10T11:00:00+05:45', 'VOLT', 1, 40);
SELECT * FROM trades;
EXPLAIN SELECT * FROM trades WHERE symbol = 'COAST';

Result:

SELECT * FROM trades

time (TIMESTAMP)symbol (SYMBOL)broker (INT)qty (LONG)
2026-09-10T05:15:00.000ZCITY4520
2026-09-10T05:15:00.000ZVOLT140
2026-09-10T05:16:00.000ZCOAST3430
2026-09-10T05:17:00.000ZCOAST5810

(4 rows)

EXPLAIN SELECT * FROM trades WHERE symbol = 'COAST'

plan (STRING)
scan table trades (1 of 1 partitions, about 4 rows)
filter while scanning: 1 condition(s)
engine: column

(3 rows)

Example: SORT BY needs real columns (sample data: sample; shows an error)

CREATE TABLE bad (time TIMESTAMP, symbol SYMBOL) TIMESERIES(time) SORT BY (sym);

Result:

Error: line 1, column 76: SORT BY column 'sym' does not exist; expected one of the columns of 'bad'

DEDUP KEY

Syntax:

CREATE TABLE name (...) TIMESERIES(time_column)
    [PARTITION BY ...] [SORT BY (...)]
    DEDUP KEY (column [, column ...], time_column)

INSERT INTO name VALUES (...)              -- a duplicate: error, nothing is saved (see dedup_insert)
INSERT OR REPLACE INTO name VALUES (...)   -- a duplicate: the old row is replaced
INSERT OR IGNORE INTO name VALUES (...)    -- a duplicate: the new row is skipped

Says which columns make a row unique. Two rows with the same values in all DEDUP KEY columns are the same row. DEDUP KEY (symbol, time) means "one row per symbol per time".

StatementKey is newKey is already there
INSERTrow insertederror, nothing from the statement is saved
INSERT OR REPLACErow insertedthe whole old row is replaced by the new one
INSERT OR IGNORErow insertedthe new row is skipped
UPDATE that would make two equal keys-error

Example: Live snapshots: a snapshot sent again replaces the old one (sample data: sample)

Setup:

CREATE TABLE snapshots (time TIMESTAMP, symbol SYMBOL, ltp DOUBLE, volume LONG)
  TIMESERIES(time) PARTITION BY DAY DEDUP KEY (symbol, time);
INSERT INTO snapshots VALUES ('2026-09-10T11:05:00+05:45', 'CITY', 513.0, 65850);
INSERT OR REPLACE INTO snapshots VALUES
  ('2026-09-10T11:05:00+05:45', 'CITY', 513.2, 66010),
  ('2026-09-10T11:06:00+05:45', 'CITY', 513.5, 67000);
INSERT OR IGNORE INTO snapshots VALUES ('2026-09-10T11:05:00+05:45', 'CITY', 999, 1);
SELECT * FROM snapshots;

Result:

INSERT OR REPLACE INTO snapshots VALUES ('2026-09-10T11:05:00+05:45', 'CITY', 513.2, 66010), ('2026-

1 row(s) inserted, 1 replaced

INSERT OR IGNORE INTO snapshots VALUES ('2026-09-10T11:05:00+05:45', 'CITY', 999, 1)

0 row(s) inserted, 1 skipped (already there)

SELECT * FROM snapshots

time (TIMESTAMP)symbol (SYMBOL)ltp (DOUBLE)volume (LONG)
2026-09-10T05:20:00.000ZCITY513.266010
2026-09-10T05:21:00.000ZCITY513.567000

(2 rows)

Example: A plain INSERT of a duplicate is refused (sample data: sample; shows an error)

INSERT INTO ochl_day VALUES ('2026-09-10', 'CITY', 504.6, 507.0, 500.7, 504.9, 183040);

Result:

Error: a row with symbol='CITY', time='2026-09-10T00:00:00.000Z' already exists in 'ochl_day'. Use INSERT OR REPLACE to overwrite it, or INSERT OR IGNORE to skip it

Example: Trade log: the same transaction twice in one import keeps one row (the last) (sample data: sample)

INSERT OR REPLACE INTO trade_log (date, transaction_id, symbol, buyer, seller, quantity, rate) VALUES
  ('2026-09-11', 200001, 'CITY', 45, 1, 10, 515.0),
  ('2026-09-11', 200001, 'CITY', 45, 1, 10, 516.0);
SELECT date, transaction_id, symbol, quantity, rate, amount FROM trade_log WHERE date IN '2026-09-11';

Result:

INSERT OR REPLACE INTO trade_log (date, transaction_id, symbol, buyer, seller, quantity, rate) VALUE

1 row(s) inserted, 1 replaced

SELECT date, transaction_id, symbol, quantity, rate, amount FROM trade_log WHERE date IN '2026-09-11

date (TIMESTAMP)transaction_id (LONG)symbol (SYMBOL)quantity (LONG)rate (DOUBLE)amount (DOUBLE)
2026-09-11T00:00:00.000Z200001CITY105165160

(1 row)

Example: Without a DEDUP KEY every copy is kept (sample data: sample)

Setup:

CREATE TABLE raw_ticks (time TIMESTAMP, symbol SYMBOL, price DOUBLE) TIMESERIES(time) PARTITION BY DAY;
INSERT INTO raw_ticks VALUES ('2026-09-10T11:00:00+05:45', 'CITY', 510);
INSERT INTO raw_ticks VALUES ('2026-09-10T11:00:00+05:45', 'CITY', 510);
SELECT count(*) AS rows_kept FROM raw_ticks;

Result:

INSERT INTO raw_ticks VALUES ('2026-09-10T11:00:00+05:45', 'CITY', 510)

1 row(s) inserted

INSERT INTO raw_ticks VALUES ('2026-09-10T11:00:00+05:45', 'CITY', 510)

1 row(s) inserted

SELECT count(*) AS rows_kept FROM raw_ticks

rows_kept (LONG)
2

(1 row)

Example: A market index: one value per minute, DEDUP KEY on the time alone (sample data: sample)

Setup:

CREATE TABLE market_index (time TIMESTAMP ROUND 1m, value DOUBLE)
  TIMESERIES(time) PARTITION BY MONTH DEDUP KEY (time);
INSERT OR REPLACE INTO market_index VALUES
  ('2026-09-10T11:00:05+05:45', 2840.1),
  ('2026-09-10T11:00:40+05:45', 2841.5),
  ('2026-09-10T11:01:10+05:45', 2842.0);
SELECT * FROM market_index;

Result:

INSERT OR REPLACE INTO market_index VALUES ('2026-09-10T11:00:05+05:45', 2840.1), ('2026-09-10T11:00

2 row(s) inserted, 1 replaced

SELECT * FROM market_index

time (TIMESTAMP)value (DOUBLE)
2026-09-10T05:15:00.000Z2841.5
2026-09-10T05:16:00.000Z2842

(2 rows)

Example: An UPDATE may not make a duplicate (sample data: sample; shows an error)

UPDATE ochl_day SET time = '2026-09-09' WHERE symbol = 'CITY' AND time IN '2026-09-10';

Result:

Error: the update would create a second row with symbol='CITY', time='2026-09-09T00:00:00.000Z' in 'ochl_day'

Example: The DEDUP KEY must include the time column (sample data: sample; shows an error)

CREATE TABLE bad (time TIMESTAMP, symbol SYMBOL, close DOUBLE) TIMESERIES(time) DEDUP KEY (symbol);

Result:

Error: DEDUP KEY must include the time column 'time', e.g. DEDUP KEY (symbol, time), or a column GENERATED ALWAYS AS (date_trunc('minute', time)) STORED

Ragged seconds: a DEDUP KEY on the minute

Syntax:

minute TIMESTAMP GENERATED ALWAYS AS (date_trunc('minute', time)) STORED
... DEDUP KEY (symbol, minute)

A feed of minute candles often sends the same minute with different seconds: 11:01:07, then 11:01:42. With DEDUP KEY (symbol, time) those are two rows. Put the minute in a stored generated column and make that the key: then both are the same row, and the later one replaces the earlier one.

Column in the key11:01:07 then 11:01:42, same symbol
timetwo rows
minute (date_trunc('minute', time))one row: the later one (with INSERT OR REPLACE, dedup_insert = 'upsert', POST /write or /imp)

Example: Minute candles: 11:01:07 and 11:01:42 of one symbol keep one row, the later (sample data: sample)

Setup:

CREATE TABLE candle_1m (time TIMESTAMP, symbol SYMBOL, close DOUBLE,
    minute TIMESTAMP GENERATED ALWAYS AS (date_trunc('minute', time)) STORED)
  TIMESERIES(time) PARTITION BY DAY DEDUP KEY (symbol, minute) WITH (dedup_insert = 'upsert');
INSERT INTO candle_1m (time, symbol, close) VALUES ('2026-09-25T11:01:07+05:45', 'CITY', 510.0);
INSERT INTO candle_1m (time, symbol, close) VALUES ('2026-09-25T11:01:42+05:45', 'CITY', 512.5),
                                                ('2026-09-25T11:02:03+05:45', 'CITY', 513.0);
SELECT time, symbol, close, minute FROM candle_1m ORDER BY time;

Result:

INSERT INTO candle_1m (time, symbol, close) VALUES ('2026-09-25T11:01:07+05:45', 'CITY', 510.0)

1 row(s) inserted

INSERT INTO candle_1m (time, symbol, close) VALUES ('2026-09-25T11:01:42+05:45', 'CITY', 512.5), ('2

1 row(s) inserted, 1 replaced

SELECT time, symbol, close, minute FROM candle_1m ORDER BY time

time (TIMESTAMP)symbol (SYMBOL)close (DOUBLE)minute (TIMESTAMP)
2026-09-25T05:16:42.000ZCITY512.52026-09-25T05:16:00.000Z
2026-09-25T05:17:03.000ZCITY5132026-09-25T05:17:00.000Z

(2 rows)

Example: Move a table that is there to the minute: seconds of one minute fold into the newest row (sample data: sample)

Setup:

CREATE TABLE ticks_1m (time TIMESTAMP, symbol SYMBOL, close DOUBLE)
  TIMESERIES(time) PARTITION BY DAY DEDUP KEY (symbol, time);
INSERT INTO ticks_1m VALUES ('2026-09-25T11:01:07+05:45', 'CITY', 510.0), ('2026-09-25T11:01:42+05:45', 'CITY', 512.5);
ALTER TABLE ticks_1m ADD COLUMN minute TIMESTAMP GENERATED ALWAYS AS (date_trunc('minute', time)) STORED;
ALTER TABLE ticks_1m DEDUP ENABLE UPSERT KEYS (symbol, minute);
SELECT time, close FROM ticks_1m;

Result:

ALTER TABLE ticks_1m ADD COLUMN minute TIMESTAMP GENERATED ALWAYS AS (date_trunc('minute', time)) ST

table 'ticks_1m' altered (2 row change(s) written)

ALTER TABLE ticks_1m DEDUP ENABLE UPSERT KEYS (symbol, minute)

table 'ticks_1m' now dedups on (symbol, minute) (1 row(s) rewritten, 1 duplicate row(s) folded: the newest row of each key was kept)

SELECT time, close FROM ticks_1m

time (TIMESTAMP)close (DOUBLE)
2026-09-25T05:16:42.000Z512.5

(1 row)

Example: An hour is too coarse to stand in for the time (sample data: sample; shows an error)

CREATE TABLE candle_1h (time TIMESTAMP, symbol SYMBOL, close DOUBLE,
    hour TIMESTAMP GENERATED ALWAYS AS (date_trunc('hour', time)) STORED)
  TIMESERIES(time) PARTITION BY DAY DEDUP KEY (symbol, hour);

Result:

Error: DEDUP KEY must include the time column 'time', e.g. DEDUP KEY (symbol, time), or a column GENERATED ALWAYS AS (date_trunc('minute', time)) STORED

A plain INSERT that replaces: dedup_insert

Syntax:

CREATE TABLE name (...) TIMESERIES(time) ... DEDUP KEY (...) WITH (dedup_insert = 'upsert')
ALTER TABLE name SET (dedup_insert = 'upsert')    -- a plain INSERT replaces the row with the same key
ALTER TABLE name SET (dedup_insert = 'error')     -- back to the default: it fails
ALTER TABLE name RESET (dedup_insert)             -- the same as 'error'

In QuestDB a plain INSERT into a DEDUP UPSERT KEYS table replaces the row with the same key. On a table made with KDB's own DEDUP KEY (...) it fails, so you do not overwrite a row by mistake. A table made with QuestDB's words, DEDUP UPSERT KEYS (...), gets QuestDB's behaviour: dedup_insert = 'upsert' is set by itself (since 2026-09-25), so an app written for QuestDB keeps its plain INSERTs unchanged. Any table can switch with ALTER TABLE ... SET (dedup_insert = ...).

The table was made withA plain INSERT of a key that is there
DEDUP KEY (...)fails (dedup_insert = 'error')
DEDUP UPSERT KEYS (...) (QuestDB)replaces the row (dedup_insert = 'upsert')
DEDUP UPSERT KEYS (...) WITH (dedup_insert = 'error')fails
dedup_insertA plain INSERT of a key that is there
'error' (default)fails: a row with ... already exists (23505)
'upsert'replaces the whole row, like INSERT OR REPLACE

Example: A price feed written for QuestDB: plain INSERTs replace the tick (sample data: sample)

Setup:

CREATE TABLE ltp (time TIMESTAMP, symbol SYMBOL, price DOUBLE)
  TIMESERIES(time) PARTITION BY DAY DEDUP KEY (symbol, time) WITH (dedup_insert = 'upsert');
INSERT INTO ltp VALUES ('2026-09-24T11:00:00+05:45', 'CITY', 510.0);
INSERT INTO ltp VALUES ('2026-09-24T11:00:00+05:45', 'CITY', 512.5), ('2026-09-24T11:01:00+05:45', 'CITY', 513.0);
SELECT * FROM ltp;

Result:

INSERT INTO ltp VALUES ('2026-09-24T11:00:00+05:45', 'CITY', 510.0)

1 row(s) inserted

INSERT INTO ltp VALUES ('2026-09-24T11:00:00+05:45', 'CITY', 512.5), ('2026-09-24T11:01:00+05:45', '

1 row(s) inserted, 1 replaced

SELECT * FROM ltp

time (TIMESTAMP)symbol (SYMBOL)price (DOUBLE)
2026-09-24T05:15:00.000ZCITY512.5
2026-09-24T05:16:00.000ZCITY513

(2 rows)

Example: Turn it on for a table that is there (sample data: sample)

Setup:

CREATE TABLE ltp (time TIMESTAMP, symbol SYMBOL, price DOUBLE)
  TIMESERIES(time) PARTITION BY DAY DEDUP KEY (symbol, time);
INSERT INTO ltp VALUES ('2026-09-24T11:00:00+05:45', 'CITY', 510.0);
ALTER TABLE ltp SET (dedup_insert = 'upsert');
INSERT INTO ltp VALUES ('2026-09-24T11:00:00+05:45', 'CITY', 511.0);
SELECT * FROM ltp;

Result:

ALTER TABLE ltp SET (dedup_insert = 'upsert')

table 'ltp': a plain INSERT of a DEDUP KEY that is there replaces the row (dedup_insert = 'upsert')

INSERT INTO ltp VALUES ('2026-09-24T11:00:00+05:45', 'CITY', 511.0)

0 row(s) inserted, 1 replaced

SELECT * FROM ltp

time (TIMESTAMP)symbol (SYMBOL)price (DOUBLE)
2026-09-24T05:15:00.000ZCITY511

(1 row)

Example: A table without a DEDUP KEY cannot have it (sample data: sample; shows an error)

Setup:

CREATE TABLE accounts (id INT PRIMARY KEY, balance DOUBLE);
ALTER TABLE accounts SET (dedup_insert = 'upsert');

Result:

Error: dedup_insert = 'upsert' needs a DEDUP KEY, and table 'accounts' has none (a table with a PRIMARY KEY takes INSERT OR REPLACE or INSERT ... ON CONFLICT)

Packed partitions: compression

Syntax:

CREATE TABLE name (...) TIMESERIES(time) PARTITION BY DAY ... WITH (compression = on)
ALTER TABLE name SET (compression = on)     -- sealed partitions are stored packed
ALTER TABLE name SET (compression = off)    -- back to the default: stored as plain arrays
ALTER TABLE name RESET (compression)        -- the same as off
COMPRESS TABLE name                         -- how far it is; packs what is left, in the background
COMPRESS TABLE name WAIT                    -- packs what is left now, then answers

A partition is sealed when a newer time bucket exists: yesterday's day, last month's month. Only sealed partitions are packed. The newest one is where rows still arrive, so it stays plain and quick to write again. A packed file keeps the same rows in far fewer bytes: times and ids that go up by small steps, broker numbers, sorted symbols and prices with a few decimals take 1 to 3 bytes instead of 8.

Measured (a floorsheet-shaped table, 3,000,000 rows, 60 days)plainpacked
bytes a row on disk76.115.5
first GROUP BY symbol, buyer after a start (reads every file), 1 thread433 ms368 ms
the same with a small memory budget (files read again and again), 1 thread316 ms183 ms
the same with every partition in memory96 ms98 ms (the same work)
ColumnMeaning
partitions, sealed, packedpartitions with a file, how many are sealed, how many files are packed
to_dopartitions still to write again: sealed plain ones while on, packed ones while off
rows, file_bytes, bytes_per_rowthe saved rows and the bytes of their files

Example: Pack the old days of a trade table (sample data: sample)

Setup:

CREATE TABLE trades (time TIMESTAMP, symbol SYMBOL, qty LONG, price DOUBLE)
  TIMESERIES(time) PARTITION BY DAY;
INSERT INTO trades VALUES ('2026-09-21T10:00:00Z', 'NABIL', 100, 512.5), ('2026-09-21T10:01:00Z', 'NABIL', 20, 513.0),
  ('2026-09-22T10:00:00Z', 'CITY', 50, 99.5), ('2026-09-23T10:00:00Z', 'NABIL', 10, 515.0);
ALTER TABLE trades SET (compression = on);
COMPRESS TABLE trades WAIT;
SELECT symbol, sum(qty), max(price) FROM trades GROUP BY symbol;

Result:

ALTER TABLE trades SET (compression = on)

table 'trades': sealed partitions are stored packed (compression = on); the sealed partitions on disk are written again in the background (COMPRESS TABLE trades shows how far, COMPRESS TABLE trades WAIT does it now)

COMPRESS TABLE trades WAIT

table (STRING)compression (STRING)partitions (LONG)sealed (LONG)packed (LONG)to_do (LONG)rows (LONG)file_bytes (LONG)bytes_per_row (DOUBLE)
tradeson322042031507.75

(1 row)

table 'trades': 2 sealed partition(s) written packed

SELECT symbol, sum(qty), max(price) FROM trades GROUP BY symbol

symbol (SYMBOL)sum(qty) (LONG)max(price) (DOUBLE)
NABIL130515
CITY5099.5

(2 rows)

Example: compression takes on or off (sample data: sample; shows an error)

Setup:

CREATE TABLE ticks (time TIMESTAMP, price DOUBLE) TIMESERIES(time) PARTITION BY DAY;
ALTER TABLE ticks SET (compression = small);

Result:

Error: line 1, column 24: compression takes on (sealed partitions are stored packed) or off (the default)

Appending without rewriting: tail files

Syntax:

kdb serve --tail-files on       -- or KDB_TAIL_FILES=on; off by default

Every save (checkpoint) used to write each changed partition whole. On PARTITION BY YEAR, SYMBOL(symbol) one more month of candles wrote each symbol's whole year again, and a live feed touching every symbol wrote every year file again at each save. Most of the bytes written, and kept in the trash, were rows already on disk.

With tail files on, a partition whose new rows all come after its last saved row gets them in a small extra file, a tail, next to its file. Its file is not read or written. Reads put the file and its tails together, so every query answers exactly the same.

What changed in the partition since the last saveWhat the save writes
only new rows, all after the last saved key (next minute, next month)one tail file with just those rows
an UPDATE or DELETE of a saved row, an upsert of an existing key, a late row before the enda delta file when the changes are few, else the whole partition (and its tails go)
it has 8 tails already, or its tails reach a quarter of its filethe whole partition, once, and its tails go

Small changes without rewriting: delta files

Syntax:

KDB_NO_PARTITION_DELTA=1        -- turns them off: every change writes the whole partition again

An UPDATE, a DELETE or an INSERT OR REPLACE of a few rows used to make the next save write the whole partition again. One replaced row in a day of a million rows wrote the whole day, and the old day went to the trash. Do that a hundred times and the trash holds a hundred days.

Now, when the changes of a partition are a small share of its rows, the save writes them to one small extra file next to it, its delta: the rows replaced, updated or added, and the keys deleted. The partition's file (and its tails) stay as they are. Reads apply the delta, so every query answers exactly the same.

What changed in the partition since it was last written wholeWhat the save writes
replaced, updated, new and deleted rows, together at most 1/16 of its rowsits delta again, with all those rows (a few hundred bytes for one row)
more than thatthe whole partition, and its delta goes
a delta file bigger than 1/8 of the partition's files, or delta bytes written since the last whole write more than the partition's files (one key replaced again and again)the whole partition, and its delta goes: compaction
a sealed partition of a table with compression = on whose file is not packed yetthe whole partition, packed

How a load finds duplicates

A load over POST /write or POST /imp builds its rows as columns and writes them in one piece per partition. How much work the duplicate check is depends on the keys:

TableA partition that has no rows yetA partition that has rows
no DEDUP KEYnothing to checknothing to check: every row is added
every SORT BY column is in the DEDUP KEY (SORT BY (symbol, time) DEDUP KEY (symbol, time))nothing to checknothing to look up: the key decides the place, so a row sent again lands on the old one
the DEDUP KEY has columns the SORT BY does not start with (SORT BY (symbol, id) DEDUP KEY (date, id))only repeats inside the loadthe whole load is checked against the stored keys at once; a stored row with the same key is replaced

Loads without the duplicate check: dedup=skip

Syntax:

POST /write?...&dedup=skip
POST /imp?...&dedup=skip

A load normally looks up every key in its day, so a day sent again replaces its rows. With dedup=skip the load does not look. Use it only for trusted bulk loads where you know the rows are new.

It matters only for a table whose DEDUP KEY is not the start of its SORT BY (the last row of the table above). For the other tables a load never looks anything up.

dedup=on (default)dedup=skip
a new dayno lookups, fastthe same
a day that has rowsthe load's keys checked against that day's keys at onceno check: faster
the same key twice in one loadthe last row staysthe same
a key that is already storedreplacedmay be there twice

The same in SQL: WITH (dedup = skip) and SET dedup

Syntax:

INSERT INTO table ... WITH (dedup = skip | on)     -- this statement only
SET dedup = skip | on | DEFAULT                     -- every INSERT and COPY FROM of this session
SHOW dedup                                          -- skip or on
RESET dedup                                         -- back to on

The words are those of ?dedup=. skip means: do not look the key up among the stored rows. A plain INSERT of a stored key then does not fail. Repeats inside the statement are still found.

Which value counts, first match wins:

Where it is setNotes
1. the statement's WITH (dedup = ...)on looks up even in a skip session
2. the session's SET dedupan HTTP session or a PostgreSQL connection; SET LOCAL dedup lasts until the transaction ends
3. the tableon: every key is looked up

Example: A day you know is new: write it without the lookups (sample data: sample)

Setup:

CREATE TABLE fills (date TIMESTAMP, id LONG, symbol SYMBOL, qty LONG)
  TIMESERIES(date) PARTITION BY DAY SORT BY (symbol, id) DEDUP KEY (date, id);
INSERT INTO fills VALUES ('2026-09-11', 1, 'CITY', 10);
INSERT INTO fills VALUES ('2026-09-12', 1, 'CITY', 11), ('2026-09-12', 2, 'COAST', 20) WITH (dedup = skip);
SELECT date, id, symbol, qty FROM fills ORDER BY date, id;

Result:

INSERT INTO fills VALUES ('2026-09-12', 1, 'CITY', 11), ('2026-09-12', 2, 'COAST', 20) WITH (dedup =

2 row(s) inserted; dedup = skip: 2 key(s) not looked up among the stored rows

SELECT date, id, symbol, qty FROM fills ORDER BY date, id

date (TIMESTAMP)id (LONG)symbol (SYMBOL)qty (LONG)
2026-09-11T00:00:00.000Z1CITY10
2026-09-12T00:00:00.000Z1CITY11
2026-09-12T00:00:00.000Z2COAST20

(3 rows)

Example: A session that skips, a key sent twice by mistake, and the repair (sample data: sample)

Setup:

CREATE TABLE fills (date TIMESTAMP, id LONG, symbol SYMBOL, qty LONG)
  TIMESERIES(date) PARTITION BY DAY SORT BY (symbol, id) DEDUP KEY (date, id);
INSERT INTO fills VALUES ('2026-09-11', 1, 'CITY', 10);
SET dedup = skip;
SHOW dedup;
INSERT INTO fills VALUES ('2026-09-11', 1, 'COAST', 12);
SELECT count(*) AS rows_for_id_1 FROM fills WHERE id = 1;
DEDUP TABLE fills FOR '2026-09-11';
RESET dedup;
SELECT id, symbol, qty FROM fills;

Result:

SET dedup = 'skip'

INSERTs and COPY FROM of this session do not look keys up among the stored rows (dedup = skip); a key that is stored already is then overwritten or there twice, DEDUP TABLE repairs it

SHOW dedup

dedup (STRING)
skip

(1 row)

INSERT INTO fills VALUES ('2026-09-11', 1, 'COAST', 12)

1 row(s) inserted; dedup = skip: 1 key(s) not looked up among the stored rows

SELECT count(*) AS rows_for_id_1 FROM fills WHERE id = 1

rows_for_id_1 (LONG)
2

(1 row)

DEDUP TABLE fills FOR '2026-09-11'

1 duplicate row(s) removed from 1 of 1 partition(s) of 'fills'

SET dedup = 'default'

INSERTs of this session look every key up again (dedup = on)

SELECT id, symbol, qty FROM fills

id (LONG)symbol (SYMBOL)qty (LONG)
1COAST12

(1 row)

Example: OR IGNORE needs the lookup (sample data: sample; shows an error)

Setup:

CREATE TABLE fills (date TIMESTAMP, id LONG, symbol SYMBOL, qty LONG)
  TIMESERIES(date) PARTITION BY DAY SORT BY (symbol, id) DEDUP KEY (date, id);
INSERT OR IGNORE INTO fills VALUES ('2026-09-11', 1, 'CITY', 10) WITH (dedup = skip);

Result:

Error: line 1, column 66: INSERT OR IGNORE cannot be used with WITH (dedup = skip): it looks every key up, and skip means no lookup

DEDUP TABLE

Syntax:

DEDUP TABLE table [FOR 'time']

Removes rows that repeat a DEDUP KEY. Of the rows with the same key, the one written last stays, as a normal load would have kept it. Use it to repair a load made with dedup=skip.

PartMeaning
tablea time-series table with a DEDUP KEY
FOR 'time'only the partition that holds this time, like '2026-09-11'. Without it: every partition.

Example: Repair a day: nothing to remove in a table loaded normally (sample data: sample)

Setup:

CREATE TABLE fills (date TIMESTAMP, id LONG, symbol SYMBOL, qty LONG)
  TIMESERIES(date) PARTITION BY DAY SORT BY (symbol, id) DEDUP KEY (date, id);
INSERT OR REPLACE INTO fills VALUES ('2026-09-11', 1, 'CITY', 10), ('2026-09-11', 2, 'COAST', 20);
DEDUP TABLE fills FOR '2026-09-11';
SELECT id, symbol, qty FROM fills ORDER BY id;

Result:

DEDUP TABLE fills FOR '2026-09-11'

0 duplicate row(s) removed from 0 of 1 partition(s) of 'fills'

SELECT id, symbol, qty FROM fills ORDER BY id

id (LONG)symbol (SYMBOL)qty (LONG)
1CITY10
2COAST20

(2 rows)

Example: DEDUP TABLE needs a DEDUP KEY (sample data: sample; shows an error)

CREATE TABLE raw (time TIMESTAMP, v LONG) TIMESERIES(time) PARTITION BY DAY;
DEDUP TABLE raw;

Result:

table 'raw' created

Error: DEDUP TABLE needs a time-series table with a DEDUP KEY; 'raw' has none

DROP PARTITION

Syntax:

ALTER TABLE table DROP PARTITION 'name' [, 'name' ...]
ALTER TABLE table DROP PARTITION OLDER THAN n HOURS | DAYS | WEEKS | MONTHS | YEARS

Deletes whole partitions: all rows of a day, month or year at once. It removes files, so it is much faster than DELETE for old data. OLDER THAN picks the partitions whose last moment is more than that long ago (alter-table); a TTL does the same by itself.

ArgumentTypeMeaning
'name'text in single quotesthe partition: '2026' (YEAR), '2026-09' (MONTH), '2026-09-10' (DAY)

Example: Delete the 2025 daily candles (sample data: sample)

SELECT count(*) AS rows_2025 FROM ochl_day WHERE time IN '2025';
ALTER TABLE ochl_day DROP PARTITION '2025';
SELECT count(*) AS rows_2025 FROM ochl_day WHERE time IN '2025';

Result:

SELECT count(*) AS rows_2025 FROM ochl_day WHERE time IN '2025'

rows_2025 (LONG)
12

(1 row)

ALTER TABLE ochl_day DROP PARTITION '2025'

1 partition(s) of 'ochl_day' dropped

SELECT count(*) AS rows_2025 FROM ochl_day WHERE time IN '2025'

rows_2025 (LONG)
0

(1 row)

Example: Trade log: delete two days at once (sample data: sample)

ALTER TABLE trade_log DROP PARTITION '2026-09-09', '2026-09-10';
SELECT count(*) AS trades_left FROM trade_log;

Result:

ALTER TABLE trade_log DROP PARTITION '2026-09-09', '2026-09-10'

2 partition(s) of 'trade_log' dropped

SELECT count(*) AS trades_left FROM trade_log

trades_left (LONG)
0

(1 row)

Example: A wrong name: the message lists the partitions (sample data: sample; shows an error)

ALTER TABLE ochl_day DROP PARTITION '2026-09';

Result:

Error: table 'ochl_day' has no partition '2026-09'; partitions: 2025, 2026

Example: The name needs quotes (sample data: sample; shows an error)

ALTER TABLE ochl_day DROP PARTITION 2025;

Result:

Error: line 1, column 37: expected a partition value in single quotes, e.g. '2026-09' but found '2025'

Example: Relational tables have no partitions (sample data: sample; shows an error)

ALTER TABLE brokers DROP PARTITION '2026';

Result:

Error: cannot drop partitions of table 'brokers': it is a relational table; only time-series tables have partitions