KDB knowledge

ALTER, RENAME and DROP TABLE

Add, drop, rename and change columns, drop partitions, set a TTL, add and drop rules and keys, turn DEDUP on or off, rename and drop tables.

Tables change when your app grows: a new column for the phone number, a better column name, old data to remove. Most of these statements change a table without copying its rows; changing a column's type and turning DEDUP on or off rewrite the rows.

StatementWhat it does
ALTER TABLE t ADD [COLUMN] c TYPE [rules]adds a column at the end
ALTER TABLE t DROP [COLUMN] cremoves a column and its data
ALTER TABLE t RENAME [COLUMN] a TO brenames a column
ALTER TABLE t ALTER [COLUMN] c TYPE LONGchanges a column's type; also SET/DROP DEFAULT, SET/DROP NOT NULL
ALTER TABLE t DROP PARTITION 'key', ...deletes whole days, months or years of a time-series table
ALTER TABLE t DROP PARTITION OLDER THAN 6 MONTHSdeletes the partitions that ended more than 6 months ago, once, now
ALTER TABLE t DROP PARTITION WHERE time < '2026-09-01'deletes the partitions whose whole time range matches (QuestDB's form)
ALTER TABLE t SET TTL 90 DAYSdrops partitions older than 90 days, by itself, from now on
ALTER TABLE t ADD [CONSTRAINT name] CHECK (...)adds a rule for every row
ALTER TABLE t ADD [CONSTRAINT name] UNIQUE (...) or FOREIGN KEY (...) REFERENCES ...adds a key
ALTER TABLE t DROP CONSTRAINT nameremoves a rule or a key
ALTER TABLE t DEDUP ENABLE UPSERT KEYS (...) / DEDUP DISABLEsets or removes the DEDUP KEY of a time-series table
ALTER TABLE t SET (dedup_insert = 'upsert') / RESET (dedup_insert)a plain INSERT of a key that is there replaces the row, or fails again (dedup_insert)
ALTER TABLE t SET (compression = on) / SET (compression = off) / RESET (compression)stores the sealed partitions packed, about 5 times smaller on disk, or plain again (compression)
ALTER TABLE t SET (resident = on) / SET (resident = off) / RESET (resident)keeps the table's partitions in memory once read, whoever needs memory (resident)
RENAME TABLE a TO b, ALTER TABLE a RENAME TO brenames a table
DROP TABLE [IF EXISTS] tdeletes a table and all its rows

What you cannot change: PARTITION BY, SORT BY, the kind of table, and the type of the time column, of a SORT BY or DEDUP KEY column and of the PRIMARY KEY. To change those, make a new table with CREATE TABLE ... AS or INSERT INTO new SELECT ... FROM old, and drop the old table. ALTER TABLE ... ENABLE TRIGGER and DISABLE TRIGGER are explained in triggers.

Several actions can go in one statement, with commas: ALTER TABLE t ADD COLUMN a INT, DROP COLUMN b. DROP PARTITION must be on its own. These statements cannot be used inside BEGIN ... COMMIT.

Example: The users table of an app grows

Setup:

CREATE TABLE users (id LONG PRIMARY KEY AUTO, email STRING NOT NULL UNIQUE);
INSERT INTO users (email) VALUES ('ram@example.com'), ('sita@example.com');
ALTER TABLE users ADD COLUMN phone STRING;
ALTER TABLE users ADD COLUMN verified BOOL NOT NULL DEFAULT FALSE;
ALTER TABLE users RENAME COLUMN email TO login_email;
SELECT id, login_email, phone, verified FROM users;

Result:

ALTER TABLE users ADD COLUMN phone STRING

table 'users' altered

ALTER TABLE users ADD COLUMN verified BOOL NOT NULL DEFAULT FALSE

table 'users' altered

ALTER TABLE users RENAME COLUMN email TO login_email

table 'users' altered

SELECT id, login_email, phone, verified FROM users

id (LONG)login_email (STRING)phone (STRING)verified (BOOL)
1ram@example.comNULLfalse
2sita@example.comNULLfalse

(2 rows)

ADD COLUMN

Syntax:

ALTER TABLE table ADD [COLUMN] [IF NOT EXISTS] column TYPE [NOT NULL] [UNIQUE] [DEFAULT expr]
                                          [REFERENCES table(column)] [ROUND interval]
                                          [AS (expr) | GENERATED ALWAYS AS (expr) STORED]
                                          [[CONSTRAINT name] CHECK (condition)]

Adds a new column at the end of the table. The word COLUMN is optional.

Example: Add a phone number and a verified flag

Setup:

CREATE TABLE users (id LONG PRIMARY KEY AUTO, email STRING NOT NULL);
INSERT INTO users (email) VALUES ('ram@example.com');
ALTER TABLE users ADD COLUMN phone STRING;
ALTER TABLE users ADD verified BOOL NOT NULL DEFAULT FALSE;
INSERT INTO users (email, phone, verified) VALUES ('sita@example.com', '9800000000', TRUE);
SELECT id, email, phone, verified FROM users;

Result:

ALTER TABLE users ADD COLUMN phone STRING

table 'users' altered

ALTER TABLE users ADD COLUMN verified BOOL NOT NULL DEFAULT FALSE

table 'users' altered

INSERT INTO users (email, phone, verified) VALUES ('sita@example.com', '9800000000', TRUE)

1 row(s) inserted

SELECT id, email, phone, verified FROM users

id (LONG)email (STRING)phone (STRING)verified (BOOL)
1ram@example.comNULLfalse
2sita@example.com9800000000true

(2 rows)

Example: Add a sector link to securities (REFERENCES)

Setup:

CREATE TABLE sectors (name STRING PRIMARY KEY);
CREATE TABLE securities (symbol SYMBOL PRIMARY KEY, name STRING NOT NULL);
INSERT INTO sectors VALUES ('Banking'), ('Energy');
INSERT INTO securities VALUES ('CITY', 'City Bank Limited');
ALTER TABLE securities ADD COLUMN sector STRING REFERENCES sectors(name);
UPDATE securities SET sector = 'Banking' WHERE symbol = 'CITY';
SELECT symbol, name, sector FROM securities;

Result:

ALTER TABLE securities ADD COLUMN sector STRING REFERENCES sectors(name)

table 'securities' altered

UPDATE securities SET sector = 'Banking' WHERE symbol = 'CITY'

1 row(s) updated

SELECT symbol, name, sector FROM securities

symbol (SYMBOL)name (STRING)sector (STRING)
CITYCity Bank LimitedBanking

(1 row)

Example: Add a computed column to the trade log

Setup:

CREATE TABLE trade_log (date TIMESTAMP, transaction_id LONG, quantity LONG, rate DOUBLE) TIMESERIES(date) DEDUP KEY (date, transaction_id);
INSERT INTO trade_log VALUES ('2026-09-10', 100010, 50, 517.0);
ALTER TABLE trade_log ADD COLUMN amount DOUBLE AS (quantity * rate);
SELECT transaction_id, quantity, rate, amount FROM trade_log;

Result:

ALTER TABLE trade_log ADD COLUMN amount DOUBLE AS (quantity * rate)

table 'trade_log' altered

SELECT transaction_id, quantity, rate, amount FROM trade_log

transaction_id (LONG)quantity (LONG)rate (DOUBLE)amount (DOUBLE)
1000105051725850

(1 row)

Example: NOT NULL without a DEFAULT (shows an error)

Setup:

CREATE TABLE users (id LONG PRIMARY KEY AUTO, email STRING NOT NULL);
ALTER TABLE users ADD COLUMN verified BOOL NOT NULL;

Result:

Error: cannot add column 'verified' to table 'users': rows already in the table need a value for a NOT NULL column; expected NOT NULL DEFAULT <value>

DROP COLUMN

Syntax:

ALTER TABLE table DROP [COLUMN] [IF EXISTS] column

Removes a column and all its values. The word COLUMN is optional.

These columns cannot be dropped:

ColumnMessage says
the TIMESERIES columnit is the TIMESERIES column
a SORT BY or DEDUP KEY columnit is part of SORT BY / DEDUP KEY
the PRIMARY KEYit is the PRIMARY KEY
a column that another table REFERENCES... REFERENCES it; drop that column first
a column used by a computed column, a CHECK or an index... uses it
the only column of the tabledrop the table instead

Example: Remove a column nobody uses

Setup:

CREATE TABLE news (id LONG PRIMARY KEY AUTO, title STRING NOT NULL, old_category STRING);
INSERT INTO news (title, old_category) VALUES ('City Bank profit rises 18%', 'banking');
ALTER TABLE news DROP COLUMN old_category;
SELECT * FROM news;

Result:

ALTER TABLE news DROP COLUMN old_category

table 'news' altered

SELECT * FROM news

id (LONG)title (STRING)
1City Bank profit rises 18%

(1 row)

Example: The symbol is part of the sort order (shows an error)

Setup:

CREATE TABLE ticks (time TIMESTAMP, symbol SYMBOL, price DOUBLE) TIMESERIES(time);
ALTER TABLE ticks DROP COLUMN symbol;

Result:

Error: cannot drop column 'symbol' from table 'ticks': it is part of SORT BY

Example: A computed column still uses it (shows an error)

Setup:

CREATE TABLE trade_log (quantity LONG, rate DOUBLE, amount DOUBLE AS (quantity * rate));
ALTER TABLE trade_log DROP COLUMN rate;

Result:

Error: cannot drop column 'rate' from table 'trade_log': computed column 'amount' uses it in AS (quantity * rate); drop 'amount' first

RENAME COLUMN

Syntax:

ALTER TABLE table RENAME [COLUMN] old_name TO new_name

Gives a column a new name. The rows do not change. The word COLUMN is optional.

Example: Rename price to ltp in a live prices table

Setup:

CREATE TABLE live_prices (time TIMESTAMP, symbol SYMBOL, price DOUBLE) TIMESERIES(time);
INSERT INTO live_prices VALUES ('2026-09-10T11:00:00+05:45', 'CITY', 513.0);
ALTER TABLE live_prices RENAME COLUMN price TO ltp;
SELECT time, symbol, ltp FROM live_prices;

Result:

ALTER TABLE live_prices RENAME COLUMN price TO ltp

table 'live_prices' altered

SELECT time, symbol, ltp FROM live_prices

time (TIMESTAMP)symbol (SYMBOL)ltp (DOUBLE)
2026-09-10T05:15:00.000ZCITY513

(1 row)

Example: A REFERENCES of another table follows the new name

Setup:

CREATE TABLE brokers (id INT PRIMARY KEY, name STRING);
CREATE TABLE trades (id LONG PRIMARY KEY, buyer INT REFERENCES brokers(id));
ALTER TABLE brokers RENAME id TO broker_no;
SHOW COLUMNS FROM trades;

Result:

ALTER TABLE brokers RENAME COLUMN id TO broker_no

table 'brokers' altered

SHOW COLUMNS FROM trades

name (STRING)type (STRING)primary_key (BOOL)not_null (BOOL)computed (BOOL)references (STRING)
idLONGtruefalsefalseNULL
buyerINTfalsefalsefalsebrokers(broker_no)

(2 rows)

Example: The new name is already used (shows an error)

Setup:

CREATE TABLE users (id LONG PRIMARY KEY, email STRING, mail STRING);
ALTER TABLE users RENAME COLUMN mail TO email;

Result:

Error: cannot rename column 'mail' of table 'users' to 'email': the table already has a column named 'email'

ALTER COLUMN

Syntax:

ALTER TABLE table ALTER [COLUMN] column [SET DATA] TYPE type [USING expression]
ALTER TABLE table ALTER [COLUMN] column SET DEFAULT expression
ALTER TABLE table ALTER [COLUMN] column DROP DEFAULT
ALTER TABLE table ALTER [COLUMN] column SET NOT NULL
ALTER TABLE table ALTER [COLUMN] column DROP NOT NULL

Changes one column. These are the PostgreSQL forms, so a PostgreSQL migration script runs.

FormWhat it does
TYPE typeconverts every stored value to the new type, like CAST. It rewrites the rows
TYPE type USING expressionthe new value of each row is expression, over the columns of that row
SET DEFAULT / DROP DEFAULTthe value new rows get when they do not give one; old rows keep theirs
SET NOT NULLfrom now on the column needs a value. Refused while a row has NULL there
DROP NOT NULLthe column may be NULL again

Example: Ages were saved as text: make them numbers

Setup:

CREATE TABLE users (id LONG PRIMARY KEY AUTO, email STRING, age STRING);
INSERT INTO users (email, age) VALUES ('ram@example.com', '31'), ('sita@example.com', 'unknown');
ALTER TABLE users ALTER COLUMN age TYPE INT USING CASE WHEN age = 'unknown' THEN NULL ELSE CAST(age AS INT) END;
SELECT id, email, age FROM users ORDER BY id;

Result:

ALTER TABLE users ALTER COLUMN age TYPE INT USING CASE WHEN age = 'unknown' THEN NULL ELSE CAST(age

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

SELECT id, email, age FROM users ORDER BY id

id (LONG)email (STRING)age (INT)
1ram@example.com31
2sita@example.comNULL

(2 rows)

Example: A default for new rows, then a NOT NULL rule

Setup:

CREATE TABLE posts (id LONG PRIMARY KEY AUTO, author STRING, lang STRING);
INSERT INTO posts (author, lang) VALUES ('ram', 'en');
ALTER TABLE posts ALTER COLUMN lang SET DEFAULT 'ne';
ALTER TABLE posts ALTER COLUMN author SET NOT NULL;
INSERT INTO posts (author) VALUES ('sita');
SELECT id, author, lang FROM posts ORDER BY id;

Result:

ALTER TABLE posts ALTER COLUMN lang SET DEFAULT 'ne'

table 'posts' altered

ALTER TABLE posts ALTER COLUMN author SET NOT NULL

table 'posts' altered

INSERT INTO posts (author) VALUES ('sita')

1 row(s) inserted

SELECT id, author, lang FROM posts ORDER BY id

id (LONG)author (STRING)lang (STRING)
1ramen
2sitane

(2 rows)

Example: A value that does not fit the new type (shows an error)

Setup:

CREATE TABLE prices (id INT PRIMARY KEY, price DOUBLE);
INSERT INTO prices VALUES (1, 12.7);
ALTER TABLE prices ALTER COLUMN price TYPE INT;

Result:

Error: cannot change column 'price' of table 'prices': a row already there does not fit (column 'price' (INT): 12.7 is not a whole number)

Example: SET NOT NULL while a row has NULL (shows an error)

Setup:

CREATE TABLE users (id LONG PRIMARY KEY AUTO, email STRING);
INSERT INTO users (email) VALUES ('ram@example.com'), (NULL);
ALTER TABLE users ALTER COLUMN email SET NOT NULL;

Result:

Error: column 'email' of table 'users' contains NULL values; set them first (UPDATE ... SET email = ... WHERE email IS NULL)

Example: A SORT BY column keeps its type (shows an error)

Setup:

CREATE TABLE ticks (time TIMESTAMP, symbol SYMBOL, qty INT) TIMESERIES(time);
ALTER TABLE ticks ALTER COLUMN symbol TYPE STRING;

Result:

Error: cannot alter column 'symbol' of table 'ticks': it is the TIMESERIES column or part of SORT BY / DEDUP KEY

DROP PARTITION

Syntax:

ALTER TABLE table DROP PARTITION [LIST] 'key' [, 'key' ...]
ALTER TABLE table DROP PARTITION OLDER THAN n HOURS | DAYS | WEEKS | MONTHS | YEARS
ALTER TABLE table DROP PARTITION WHERE time_column < '2026-09-01'

Deletes whole partitions of a time-series table: all rows of that day, month or year. It is much faster than DELETE for old data. OLDER THAN works out the keys itself, by the same rule as a TTL: a partition goes when its last moment is more than that long ago. It is the form for a timer that cleans up on a set day, where a TTL cleans up by itself all the time.

LIST and WHERE are QuestDB's forms. LIST 'a', 'b' is the same as 'a', 'b'. WHERE drops each partition whose whole time range matches the condition:

WHEREPartitions dropped
time < '2026-09-22' (a day boundary, DAY partitions)every day before 2026-09-22
time < '2026-09-22T12:00' (inside a day)every day before 2026-09-22; the day 2026-09-22 is kept and named in the answer
time IN '2026-08' OR time >= '2026-09-20'August, and every day from 2026-09-20 on
Table hasPartition keys look like
PARTITION BY YEAR'2025'
PARTITION BY MONTH'2026-08'
PARTITION BY DAY'2026-09-09'

Example: Remove the 2025 daily candles

Setup:

CREATE TABLE ochl_day (time TIMESTAMP ROUND 1d, symbol SYMBOL, close DOUBLE) TIMESERIES(time) PARTITION BY YEAR DEDUP KEY (symbol, time);
INSERT INTO ochl_day VALUES ('2025-12-31', 'CITY', 542.4), ('2026-09-10', 'CITY', 504.9);
SELECT count(*) AS rows_2025 FROM ochl_day WHERE time IN '2025';
ALTER TABLE ochl_day DROP PARTITION '2025';
SELECT time, symbol, close FROM ochl_day;

Result:

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

rows_2025 (LONG)
1

(1 row)

ALTER TABLE ochl_day DROP PARTITION '2025'

1 partition(s) of 'ochl_day' dropped

SELECT time, symbol, close FROM ochl_day

time (TIMESTAMP)symbol (SYMBOL)close (DOUBLE)
2026-09-10T00:00:00.000ZCITY504.9

(1 row)

Example: Drop what ended more than a year ago (by hand, or from a monthly timer)

Setup:

CREATE TABLE trades_log (time TIMESTAMP, symbol SYMBOL, qty LONG) TIMESERIES(time) PARTITION BY MONTH;
INSERT INTO trades_log VALUES ('2024-01-15', 'CITY', 100), ('2024-02-15', 'CITY', 200), ('2099-01-15', 'CITY', 300);
ALTER TABLE trades_log DROP PARTITION OLDER THAN 1 YEAR;
SELECT time, symbol, qty FROM trades_log;

Result:

ALTER TABLE trades_log DROP PARTITION OLDER THAN 1 YEAR

2 partition(s) of 'trades_log' dropped

SELECT time, symbol, qty FROM trades_log

time (TIMESTAMP)symbol (SYMBOL)qty (LONG)
2099-01-15T00:00:00.000ZCITY300

(1 row)

Example: Drop two days of trade log at once

Setup:

CREATE TABLE trade_log (date TIMESTAMP ROUND 1d, transaction_id LONG, symbol SYMBOL) TIMESERIES(date) PARTITION BY DAY DEDUP KEY (date, transaction_id);
INSERT INTO trade_log VALUES ('2026-09-08', 1, 'CITY'), ('2026-09-09', 2, 'COAST'), ('2026-09-10', 3, 'TELCO');
ALTER TABLE trade_log DROP PARTITION '2026-09-08', '2026-09-09';
SELECT date, transaction_id, symbol FROM trade_log;

Result:

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

2 partition(s) of 'trade_log' dropped

SELECT date, transaction_id, symbol FROM trade_log

date (TIMESTAMP)transaction_id (LONG)symbol (SYMBOL)
2026-09-10T00:00:00.000Z3TELCO

(1 row)

Example: QuestDB's forms: LIST, and WHERE on the time column

Setup:

CREATE TABLE floorsheet (date TIMESTAMP, symbol SYMBOL, qty LONG) TIMESTAMP(date) PARTITION BY DAY;
INSERT INTO floorsheet VALUES ('2026-09-20T10:00', 'CITY', 1), ('2026-09-21T10:00', 'CITY', 2), ('2026-09-22T10:00', 'CITY', 3), ('2026-09-23T10:00', 'CITY', 4);
ALTER TABLE floorsheet DROP PARTITION LIST '2026-09-20';
ALTER TABLE floorsheet DROP PARTITION WHERE date < '2026-09-22T12:00';
SELECT date, qty FROM floorsheet;

Result:

ALTER TABLE floorsheet DROP PARTITION '2026-09-20'

1 partition(s) of 'floorsheet' dropped

ALTER TABLE floorsheet DROP PARTITION WHERE date < '2026-09-22T12:00'

1 partition(s) of 'floorsheet' dropped; kept partition(s) only part of which matches: 2026-09-22 (use DELETE for those rows)

SELECT date, qty FROM floorsheet

date (TIMESTAMP)qty (LONG)
2026-09-22T10:00:00.000Z3
2026-09-23T10:00:00.000Z4

(2 rows)

With LIST, as in QuestDB, a partition that is not there is skipped and the others are still dropped. Without LIST, a partition that is not there is an error and nothing is dropped.

Example: LIST skips a day that has no partition

Setup:

CREATE TABLE floorsheet (date TIMESTAMP, symbol SYMBOL, qty LONG) TIMESTAMP(date) PARTITION BY DAY;
INSERT INTO floorsheet VALUES ('2026-09-20T10:00', 'CITY', 1), ('2026-09-21T10:00', 'CITY', 2);
ALTER TABLE floorsheet DROP PARTITION LIST '2026-09-20', '2000-01-01';
SELECT date, qty FROM floorsheet;

Result:

ALTER TABLE floorsheet DROP PARTITION '2026-09-20', '2000-01-01'

1 partition(s) of 'floorsheet' dropped

SELECT date, qty FROM floorsheet

date (TIMESTAMP)qty (LONG)
2026-09-21T10:00:00.000Z2

(1 row)

Example: WHERE takes only the time column (shows an error)

Setup:

CREATE TABLE floorsheet (date TIMESTAMP, symbol SYMBOL, qty LONG) TIMESTAMP(date) PARTITION BY DAY;
INSERT INTO floorsheet VALUES ('2026-09-20T10:00', 'CITY', 1);
ALTER TABLE floorsheet DROP PARTITION WHERE qty > 0;

Result:

Error: DROP PARTITION WHERE compares the time column 'date' with fixed times only (=, <>, <, <=, >, >=, BETWEEN, IN, joined by AND, OR, NOT), as in WHERE date < '2026-09-01'

Example: A month key for a table partitioned by day (shows an error)

Setup:

CREATE TABLE trade_log (date TIMESTAMP, transaction_id LONG) TIMESERIES(date) PARTITION BY DAY;
INSERT INTO trade_log VALUES ('2026-09-10', 1);
ALTER TABLE trade_log DROP PARTITION '2026-09';

Result:

Error: table 'trade_log' has no partition '2026-09'; partitions: 2026-09-10

SET TTL

Syntax:

ALTER TABLE table SET TTL n HOURS | DAYS | WEEKS | MONTHS | YEARS
ALTER TABLE table SET TTL NONE
CREATE TABLE ... TIMESERIES(time) PARTITION BY DAY TTL 90 DAYS

A TTL (time to live) keeps a time-series table small. KDB drops whole partitions whose time ended more than the TTL ago. It never deletes single rows.

Example: Keep 90 days of live prices

Setup:

CREATE TABLE live_prices (time TIMESTAMP, symbol SYMBOL, ltp DOUBLE) TIMESERIES(time) PARTITION BY DAY;
ALTER TABLE live_prices SET TTL 90 DAYS;
SHOW CREATE TABLE live_prices;

Result:

ALTER TABLE live_prices SET TTL 90 DAYS

table 'live_prices' drops partitions older than 90 DAYS

SHOW CREATE TABLE live_prices

create_sql (STRING)
CREATE TABLE live_prices ( time TIMESTAMP, symbol SYMBOL, ltp DOUBLE ) TIMESERIES(time) PARTITION BY DAY SORT BY (symbol, time) TTL 90 DAYS

(1 row)

Example: TTL when the table is made, and taken off again

Setup:

CREATE TABLE candles_1m (time TIMESTAMP, symbol SYMBOL, close DOUBLE) TIMESERIES(time) PARTITION BY MONTH TTL 2 YEARS;
ALTER TABLE candles_1m SET TTL NONE;
SHOW CREATE TABLE candles_1m;

Result:

ALTER TABLE candles_1m SET TTL NONE

table 'candles_1m' keeps its partitions (no TTL)

SHOW CREATE TABLE candles_1m

create_sql (STRING)
CREATE TABLE candles_1m ( time TIMESTAMP, symbol SYMBOL, close DOUBLE ) TIMESERIES(time) PARTITION BY MONTH SORT BY (symbol, time)

(1 row)

Example: A relational table has no partitions to drop (shows an error)

Setup:

CREATE TABLE notes (id LONG PRIMARY KEY, body STRING);
ALTER TABLE notes SET TTL 30 DAYS;

Result:

Error: TTL drops whole time partitions, so it needs a time-series table with PARTITION BY DAY, MONTH or YEAR; table 'notes' has none

DEDUP ENABLE and DEDUP DISABLE

Syntax:

ALTER TABLE table DEDUP ENABLE UPSERT KEYS (column, ..., time_column)
ALTER TABLE table DEDUP DISABLE

Sets or removes the DEDUP KEY of a time-series table. It is QuestDB's statement; UPSERT may be left out.

Example: A tick table loaded twice: turn DEDUP on and the copies fold

Setup:

CREATE TABLE ticks (time TIMESTAMP, symbol SYMBOL, price DOUBLE) TIMESERIES(time) PARTITION BY DAY;
INSERT INTO ticks VALUES ('2026-09-10T11:00:00+05:45', 'CITY', 510), ('2026-09-10T11:01:00+05:45', 'CITY', 511);
INSERT INTO ticks VALUES ('2026-09-10T11:00:00+05:45', 'CITY', 510.5);
ALTER TABLE ticks DEDUP ENABLE UPSERT KEYS (time, symbol);
SELECT time, symbol, price FROM ticks;

Result:

ALTER TABLE ticks DEDUP ENABLE UPSERT KEYS (time, symbol)

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

SELECT time, symbol, price FROM ticks

time (TIMESTAMP)symbol (SYMBOL)price (DOUBLE)
2026-09-10T05:15:00.000ZCITY510.5
2026-09-10T05:16:00.000ZCITY511

(2 rows)

Example: The key needs the time column (shows an error)

Setup:

CREATE TABLE ticks (time TIMESTAMP, symbol SYMBOL, price DOUBLE) TIMESERIES(time);
ALTER TABLE ticks DEDUP ENABLE UPSERT KEYS (symbol);

Result:

Error: DEDUP UPSERT KEYS must include the time column 'time' of table 'ticks', or a stored column GENERATED ALWAYS AS (date_trunc('minute', time)) STORED

ADD CONSTRAINT ... CHECK

Syntax:

ALTER TABLE table ADD [CONSTRAINT name] CHECK (condition)

Adds a rule that every row must follow. KDB first reads every row: if one row already breaks the rule, the rule is refused and the message names that row.

Example: No negative balances from now on: the rule stops a bad UPDATE (shows an error)

Setup:

CREATE TABLE accounts (id INT PRIMARY KEY AUTO, owner STRING NOT NULL, balance DECIMAL(2) NOT NULL DEFAULT 0);
INSERT INTO accounts (owner, balance) VALUES ('Ram', 1000.00), ('Sita', 0);
ALTER TABLE accounts ADD CONSTRAINT balance_not_negative CHECK (balance >= 0);
UPDATE accounts SET balance = balance - 50 WHERE owner = 'Sita';

Result:

table 'accounts' altered

Error: CHECK constraint 'balance_not_negative' of table 'accounts' failed: balance >= 0 is false for the row id='2', balance='-50.00'

Example: A row already breaks the rule (shows an error)

Setup:

CREATE TABLE accounts (id INT PRIMARY KEY AUTO, owner STRING NOT NULL, balance DECIMAL(2) NOT NULL DEFAULT 0);
INSERT INTO accounts (owner, balance) VALUES ('Ram', -20.00);
ALTER TABLE accounts ADD CONSTRAINT balance_not_negative CHECK (balance >= 0);

Result:

Error: cannot add the constraint: a row already in table 'accounts' breaks it (CHECK constraint 'balance_not_negative' of table 'accounts' failed: balance >= 0 is false for the row id='1', balance='-20.00'); fix or delete such rows first

ADD UNIQUE, ADD FOREIGN KEY

Syntax:

ALTER TABLE table ADD [CONSTRAINT name] UNIQUE (column [, column ...])
ALTER TABLE table ADD [CONSTRAINT name] FOREIGN KEY (column [, ...]) REFERENCES other (column [, ...])
                      [ON DELETE action] [ON UPDATE action] [NOT VALID]

Adds a key to a table that already has rows, the PostgreSQL way. KDB first reads every row.

FormKDB checks firstAfter it
UNIQUE (...)no two rows have the same valuesa new duplicate is refused
FOREIGN KEY (...) REFERENCES other (...)every row has a matching row in othera new row must point at a row that is there
... NOT VALIDnothing: rows already there are not checkednew rows are checked

Example: Trades point at brokers from now on

Setup:

CREATE TABLE brokers (id INT PRIMARY KEY, name STRING NOT NULL);
CREATE TABLE trades (id LONG PRIMARY KEY, buyer INT, qty LONG);
INSERT INTO brokers VALUES (1, 'Keystone Securities'), (45, 'Imperial Securities');
INSERT INTO trades VALUES (1, 1, 10), (2, 45, 20);
ALTER TABLE trades ADD CONSTRAINT trades_buyer_fk FOREIGN KEY (buyer) REFERENCES brokers(id);
ALTER TABLE brokers ADD CONSTRAINT brokers_name_key UNIQUE (name);
SHOW CREATE TABLE trades;

Result:

ALTER TABLE trades ADD CONSTRAINT trades_buyer_fk FOREIGN KEY (buyer) REFERENCES brokers (id)

table 'trades' altered

ALTER TABLE brokers ADD CONSTRAINT brokers_name_key UNIQUE (name)

table 'brokers' altered

SHOW CREATE TABLE trades

create_sql (STRING)
CREATE TABLE trades ( id LONG PRIMARY KEY, buyer INT CONSTRAINT trades_buyer_fk REFERENCES brokers(id), qty LONG )

(1 row)

Example: Old rows break the rule: NOT VALID checks only new rows (shows an error)

Setup:

CREATE TABLE brokers (id INT PRIMARY KEY, name STRING NOT NULL);
CREATE TABLE trades (id LONG PRIMARY KEY, buyer INT);
INSERT INTO brokers VALUES (1, 'Keystone Securities');
INSERT INTO trades VALUES (1, 1), (2, 77);
ALTER TABLE trades ADD CONSTRAINT trades_buyer_fk FOREIGN KEY (buyer) REFERENCES brokers(id) NOT VALID;
INSERT INTO trades VALUES (3, 99);

Result:

table 'trades' altered

Error: column 'buyer' = '99' has no matching row in brokers(id)

Example: Two rows already have the same value (shows an error)

Setup:

CREATE TABLE users (id LONG PRIMARY KEY, email STRING);
INSERT INTO users VALUES (1, 'ram@example.com'), (2, 'ram@example.com');
ALTER TABLE users ADD UNIQUE (email);

Result:

Error: cannot add constraint 'users_email_key': two rows of table 'users' have email='ram@example.com' (duplicate)

DROP CONSTRAINT

Syntax:

ALTER TABLE table DROP CONSTRAINT name

Removes a CHECK rule, a UNIQUE key or a FOREIGN KEY by its name. SHOW CREATE TABLE shows the names, also the names KDB made for you (like accounts_balance_check). The PRIMARY KEY cannot be dropped: the rows are stored by it.

Example: Remove a limit that is too strict

Setup:

CREATE TABLE ledger (id LONG PRIMARY KEY AUTO, amount DECIMAL(2) NOT NULL, CONSTRAINT max_amount CHECK (amount <= 100000));
ALTER TABLE ledger DROP CONSTRAINT max_amount;
INSERT INTO ledger (amount) VALUES (250000.00);
SELECT id, amount FROM ledger;

Result:

ALTER TABLE ledger DROP CONSTRAINT max_amount

table 'ledger' altered

INSERT INTO ledger (amount) VALUES (250000.00)

1 row(s) inserted

SELECT id, amount FROM ledger

id (LONG)amount (DECIMAL(2))
1250000.00

(1 row)

Example: A wrong constraint name (shows an error)

Setup:

CREATE TABLE accounts (id INT PRIMARY KEY, balance DECIMAL(2) CHECK (balance >= 0));
ALTER TABLE accounts DROP CONSTRAINT balance_check;

Result:

Error: table 'accounts' has no constraint 'balance_check' (it has: accounts_pkey, accounts_balance_check)

RENAME TABLE

Syntax:

RENAME TABLE old_name TO new_name

Gives a table a new name. REFERENCES in other tables follow the new name.

Example: Rename a table; the REFERENCES follows

Setup:

CREATE TABLE brokers (id INT PRIMARY KEY, name STRING);
CREATE TABLE trades (id LONG PRIMARY KEY, buyer INT REFERENCES brokers(id));
RENAME TABLE brokers TO members;
SHOW COLUMNS FROM trades;

Result:

RENAME TABLE brokers TO members

table 'brokers' renamed to 'members'

SHOW COLUMNS FROM trades

name (STRING)type (STRING)primary_key (BOOL)not_null (BOOL)computed (BOOL)references (STRING)
idLONGtruefalsefalseNULL
buyerINTfalsefalsefalsemembers(id)

(2 rows)

Example: The new name is taken (shows an error)

Setup:

CREATE TABLE posts (id LONG PRIMARY KEY);
CREATE TABLE posts_old (id LONG PRIMARY KEY);
RENAME TABLE posts TO posts_old;

Result:

Error: cannot rename table 'posts' to 'posts_old': a table named 'posts_old' already exists

DROP TABLE

Syntax:

DROP TABLE [IF EXISTS] table [CASCADE | RESTRICT]

Deletes a table with all its rows and its indexes. With IF EXISTS, a table that is not there is not an error.

Example: Remove a test table; safe to run twice

Setup:

CREATE TABLE test_ticks (time TIMESTAMP, price DOUBLE) TIMESERIES(time);
DROP TABLE test_ticks;
DROP TABLE IF EXISTS test_ticks;
SHOW TABLES;

Result:

DROP TABLE test_ticks

table 'test_ticks' dropped; UNDROP TABLE test_ticks brings it back within 7 days

DROP TABLE IF EXISTS test_ticks

table 'test_ticks' does not exist

SHOW TABLES

name (STRING)kind (STRING)partition (STRING)columns (INT)rows (LONG)

(0 rows)

Example: Brokers are still used by the trades (shows an error)

Setup:

CREATE TABLE brokers (id INT PRIMARY KEY, name STRING);
CREATE TABLE trades (id LONG PRIMARY KEY, buyer INT REFERENCES brokers(id));
DROP TABLE brokers;

Result:

Error: cannot drop table 'brokers': column 'buyer' of table 'trades' REFERENCES it; drop that column or table first

Example: A table that is not there (shows an error)

DROP TABLE nothing;

Result:

Error: cannot drop table 'nothing': it does not exist

Example: CASCADE and RESTRICT on tables nothing uses

Setup:

CREATE TABLE test_a (id INT PRIMARY KEY);
CREATE TABLE test_b (id INT PRIMARY KEY);
DROP TABLE test_a CASCADE;
DROP TABLE test_b RESTRICT;
SHOW TABLES;

Result:

DROP TABLE test_a

table 'test_a' dropped; UNDROP TABLE test_a brings it back within 7 days

DROP TABLE test_b

table 'test_b' dropped; UNDROP TABLE test_b brings it back within 7 days

SHOW TABLES

name (STRING)kind (STRING)partition (STRING)columns (INT)rows (LONG)

(0 rows)

Example: CASCADE would have to drop a view: refused (shows an error)

Setup:

CREATE TABLE test_prices (symbol SYMBOL, price DOUBLE);
CREATE VIEW test_expensive AS SELECT symbol FROM test_prices WHERE price > 1000;
DROP TABLE test_prices CASCADE;

Result:

Error: DROP TABLE ... CASCADE is not supported: it would not drop view 'test_expensive', which uses table 'test_prices'. Drop the dependent views first (DROP VIEW test_expensive), then DROP TABLE test_prices

SET (resident = on): keep a table in memory

Syntax:

ALTER TABLE <t> SET (resident = on | off)
ALTER TABLE <t> RESET (resident)
CREATE TABLE <t> (...) WITH (resident = on)

A table an app reads on every request (sessions, users, symbols) should not be read from disk again because another query needed memory. With resident = on, its partitions are never unloaded once a read has loaded them: not for a big scan of the same database, not for another database, not for the process memory limit.

Example: The sessions table of an app stays loaded

Setup:

CREATE TABLE sessions (token STRING PRIMARY KEY, user_id LONG);
ALTER TABLE sessions SET (resident = on);

Result:

table 'sessions' stays in memory once loaded (resident = on); it counts against the database's MEMORY_LIMIT