# INSERT
> Add rows with INSERT, decide what happens with duplicate keys, get new ids back with RETURNING, and write times correctly.
- Category: Writing data
- Keywords: insert, add rows, new row, VALUES, INTO, column list, multi-row insert, upsert, INSERT OR REPLACE, REPLACE, INSERT OR IGNORE, IGNORE, ON CONFLICT, duplicate key, already exists, dedup, RETURNING, returning id, new id, last insert id, time zone, +05:45, Nepal time, UTC, INSERT SELECT, copy rows
- See also: [UPDATE and DELETE](update-delete.md), [Loading many rows](loading-data.md), [Constraints](constraints.md), [Column types](column-types.md), [CREATE TABLE](create-table.md), [Transactions](transactions.md), [Time and time zones](time-and-time-zones.md)
`INSERT` adds rows to a table. One `INSERT` can add many rows, and it is **all or nothing**: when one row is bad, no row of that statement is saved. When KDB says `inserted`, the rows are already on the disk.
| Statement | The key is new | The key is already there |
|---|---|---|
| [`INSERT`](#insert) | row added | **error**, nothing saved |
| [`INSERT OR REPLACE`](#insert-or-replace) | row added | the old row is replaced by the new one |
| [`INSERT OR IGNORE`](#insert-or-ignore) | row added | the new row is skipped |
A real example: today's market candles arrive; one of them was already loaded.
**Example: Load daily candles; a repeated row is replaced, not doubled**
Setup:
```sql
CREATE TABLE ochl_day (time TIMESTAMP ROUND 1d, symbol SYMBOL, open DOUBLE, high DOUBLE, low DOUBLE, close DOUBLE, volume DOUBLE)
TIMESERIES(time) PARTITION BY YEAR DEDUP KEY (symbol, time);
INSERT INTO ochl_day VALUES ('2026-09-13', 'CITY', 505.0, 509.0, 503.1, 508.2, 91000);
```
```sql
INSERT OR REPLACE INTO ochl_day (time, symbol, open, high, low, close, volume) VALUES
('2026-09-13', 'CITY', 505.0, 509.0, 503.1, 507.6, 91000),
('2026-09-13', 'COAST', 376.0, 379.9, 374.0, 378.5, 120500),
('2026-09-13', 'VOLT', 191.0, 194.2, 190.1, 193.7, 64000);
SELECT time, symbol, close FROM ochl_day ORDER BY symbol;
```
Result:
`INSERT OR REPLACE INTO ochl_day (time, symbol, open, high, low, close, volume) VALUES ('2026-09-13',`
```text
2 row(s) inserted, 1 replaced
```
`SELECT time, symbol, close FROM ochl_day ORDER BY symbol`
| time (TIMESTAMP) | symbol (SYMBOL) | close (DOUBLE) |
|---|---|---|
| 2026-09-13T00:00:00.000Z | CITY | 507.6 |
| 2026-09-13T00:00:00.000Z | COAST | 378.5 |
| 2026-09-13T00:00:00.000Z | VOLT | 193.7 |
(3 rows)
## INSERT INTO ... VALUES
Syntax:
```sql
INSERT INTO table [(column, ...)] VALUES (value, ...) [, (value, ...) ...] [RETURNING ...]
```
Adds one row for each `(...)` after `VALUES`.
| Part | Meaning |
|---|---|
| `table` | a table (not a view) |
| `(column, ...)` | optional. The columns the values are for, in any order. Columns you leave out get their `DEFAULT`, or `NULL`, or the next [AUTO](constraints.md#auto) id |
| without a column list | give a value for **every** stored column, in table order; computed columns are skipped |
| `value` | a constant (`'CITY'`, `504.9`, `NULL`, `TRUE`) or an expression without columns (`1 + 1`, `now()`, `CAST('7' AS INT)`) |
- **Returns:** `N row(s) inserted`.
- Each value is converted to the column type; see [column types](column-types.md). Text goes in single quotes; a `'` inside is written `''`.
- **Notes:** many rows in one `INSERT` are **much** faster than one `INSERT` per row. A value cannot be a subquery or use a column. Every row needs the same number of values. The rules of the table ([constraints](constraints.md)) are checked for every row.
**Example: Register two app users; the id and the time come by themselves**
Setup:
```sql
CREATE TABLE users (id LONG PRIMARY KEY AUTO, email STRING NOT NULL UNIQUE, name STRING, created TIMESTAMP DEFAULT now());
```
```sql
INSERT INTO users (email, name) VALUES ('ram@example.com', 'Ram Sharma'), ('sita@example.com', 'Sita Rai');
SELECT id, email, name, created IS NOT NULL AS has_created FROM users;
```
Result:
`INSERT INTO users (email, name) VALUES ('ram@example.com', 'Ram Sharma'), ('sita@example.com', 'Sita`
```text
2 row(s) inserted
```
`SELECT id, email, name, created IS NOT NULL AS has_created FROM users`
| id (LONG) | email (STRING) | name (STRING) | has_created (BOOL) |
|---|---|---|---|
| 1 | ram@example.com | Ram Sharma | true |
| 2 | sita@example.com | Sita Rai | true |
(2 rows)
**Example: A forum post with text in another language (Nepali) and a quote inside**
Setup:
```sql
CREATE TABLE posts (id LONG PRIMARY KEY AUTO, author STRING NOT NULL, body STRING NOT NULL);
```
```sql
INSERT INTO posts (author, body) VALUES
('ram', 'City''s dividend looks good.'),
('sita', 'नमस्ते, यो राम्रो छ।');
SELECT id, author, body FROM posts;
```
Result:
`INSERT INTO posts (author, body) VALUES ('ram', 'City''s dividend looks good.'), ('sita', 'नमस्ते, य`
```text
2 row(s) inserted
```
`SELECT id, author, body FROM posts`
| id (LONG) | author (STRING) | body (STRING) |
|---|---|---|
| 1 | ram | City's dividend looks good. |
| 2 | sita | नमस्ते, यो राम्रो छ। |
(2 rows)
**Example: Values can be expressions without columns**
Setup:
```sql
CREATE TABLE ledger (id LONG PRIMARY KEY AUTO, amount DECIMAL(2) NOT NULL, fee DECIMAL(2), created TIMESTAMP);
```
```sql
INSERT INTO ledger (amount, fee, created) VALUES (1000.00, round(1000.00 * 0.004, 2), now());
SELECT id, amount, fee, created > '2026-01-01' AS recent FROM ledger;
```
Result:
`INSERT INTO ledger (amount, fee, created) VALUES (1000.00, round(1000.00 * 0.004, 2), now())`
```text
1 row(s) inserted
```
`SELECT id, amount, fee, created > '2026-01-01' AS recent FROM ledger`
| id (LONG) | amount (DECIMAL(2)) | fee (DECIMAL(2)) | recent (BOOL) |
|---|---|---|---|
| 1 | 1000.00 | 4.00 | true |
(1 row)
**Example: One bad row: no row of the statement is saved** (shows an error)
Setup:
```sql
CREATE TABLE brokers (id INT PRIMARY KEY, name STRING NOT NULL);
```
```sql
INSERT INTO brokers VALUES (1, 'Keystone Securities'), (2, NULL), (3, 'Vision Securities');
```
Result:
Error: `row 2: column 'name' (STRING): cannot be NULL`
**Example: The wrong number of values** (shows an error)
Setup:
```sql
CREATE TABLE brokers (id INT PRIMARY KEY, name STRING NOT NULL);
```
```sql
INSERT INTO brokers VALUES (1, 'Keystone Securities'), (2);
```
Result:
Error: `line 1, column 56: row 2 has 1 value; expected 2 like the first row`
## Duplicate keys
Syntax:
```sql
PRIMARY KEY (column) relational table
DEDUP KEY (column, ..., time) time-series table
UNIQUE (column) any table
```
How KDB decides that a new row is "already there":
| Table | Two rows are the same when |
|---|---|
| relational with a `PRIMARY KEY` | they have the same key value |
| time-series with a `DEDUP KEY` | all `DEDUP KEY` columns are the same (after `ROUND` of the time) |
| without a key | never: every `INSERT` adds rows, also exact copies |
- Duplicates **inside one statement** count too: with `INSERT` it is an error, with `INSERT OR REPLACE` the **last** one wins, with `INSERT OR IGNORE` the first one stays.
- **Returns:** `a row with symbol='CITY', time='...' already exists in 'ochl_day'. Use INSERT OR REPLACE to overwrite it, or INSERT OR IGNORE to skip it`.
- **Notes:** a `UNIQUE` column is always an error on a clash, also with `OR REPLACE` and `OR IGNORE`: they only look at the primary key or dedup key. If you did not expect a duplicate, check the time zone (`+05:45` missing?) and `ROUND` on the time column.
**Example: A candle for the same symbol and day** (shows an error)
Setup:
```sql
CREATE TABLE ochl_day (time TIMESTAMP ROUND 1d, symbol SYMBOL, close DOUBLE) TIMESERIES(time) DEDUP KEY (symbol, time);
INSERT INTO ochl_day VALUES ('2026-09-10', 'CITY', 504.9);
```
```sql
INSERT INTO ochl_day VALUES ('2026-09-10T14:00:00+05:45', 'CITY', 505.0);
```
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: The same key twice in one INSERT OR REPLACE: the last row wins**
Setup:
```sql
CREATE TABLE live_data (timestamp TIMESTAMP, symbol SYMBOL, ltp DOUBLE) TIMESERIES(timestamp) DEDUP KEY (timestamp, symbol);
```
```sql
INSERT OR REPLACE INTO live_data VALUES
('2026-09-10T11:05:00+05:45', 'CITY', 513.0),
('2026-09-10T11:05:00+05:45', 'CITY', 513.4);
SELECT timestamp, symbol, ltp FROM live_data;
```
Result:
`INSERT OR REPLACE INTO live_data VALUES ('2026-09-10T11:05:00+05:45', 'CITY', 513.0), ('2026-09-10T1`
```text
1 row(s) inserted, 1 replaced
```
`SELECT timestamp, symbol, ltp FROM live_data`
| timestamp (TIMESTAMP) | symbol (SYMBOL) | ltp (DOUBLE) |
|---|---|---|
| 2026-09-10T05:20:00.000Z | CITY | 513.4 |
(1 row)
## INSERT OR REPLACE
Syntax:
```sql
INSERT OR REPLACE INTO table [(column, ...)] VALUES (...), ... [RETURNING ...]
```
Adds rows with new keys, and **replaces** rows whose key is already there. This is an **upsert**. It is the best statement for imports: the same file can be loaded again and again without double rows.
- The **whole old row** is replaced. Columns you do not give get their `DEFAULT` or `NULL`, **not** the old values. To change only some columns, use [UPDATE](update-delete.md#update).
- **Returns:** `N row(s) inserted, M replaced`.
- **Notes:** on a table without a key it works like `INSERT`. PostgreSQL writes `INSERT ... ON CONFLICT (key) DO UPDATE`; MySQL writes `REPLACE INTO`. QuestDB replaces rows by itself with `DEDUP UPSERT KEYS`; KDB does too on a table made with those words, and otherwise only when you write `OR REPLACE` ([dedup_insert](partitions-and-dedup.md#dedup-insert)).
**Example: The CITY close price was wrong: send the full row again**
Setup:
```sql
CREATE TABLE ochl_day (time TIMESTAMP ROUND 1d, symbol SYMBOL, open DOUBLE, high DOUBLE, low DOUBLE, close DOUBLE, volume DOUBLE) TIMESERIES(time) DEDUP KEY (symbol, time);
INSERT INTO ochl_day VALUES ('2026-09-13', 'CITY', 505.0, 509.0, 503.1, 508.2, 91000);
```
```sql
INSERT OR REPLACE INTO ochl_day VALUES ('2026-09-13', 'CITY', 505.0, 509.0, 503.1, 507.6, 91000);
SELECT time, symbol, close, volume FROM ochl_day;
```
Result:
`INSERT OR REPLACE INTO ochl_day VALUES ('2026-09-13', 'CITY', 505.0, 509.0, 503.1, 507.6, 91000)`
```text
0 row(s) inserted, 1 replaced
```
`SELECT time, symbol, close, volume FROM ochl_day`
| time (TIMESTAMP) | symbol (SYMBOL) | close (DOUBLE) | volume (DOUBLE) |
|---|---|---|---|
| 2026-09-13T00:00:00.000Z | CITY | 507.6 | 91000 |
(1 row)
**Example: Careful: columns you leave out lose their old values**
Setup:
```sql
CREATE TABLE users (id LONG PRIMARY KEY, email STRING, phone STRING, verified BOOL DEFAULT FALSE);
INSERT INTO users VALUES (1, 'ram@example.com', '9800000000', TRUE);
```
```sql
INSERT OR REPLACE INTO users (id, email) VALUES (1, 'ram.sharma@example.com');
SELECT id, email, phone, verified FROM users;
```
Result:
`INSERT OR REPLACE INTO users (id, email) VALUES (1, 'ram.sharma@example.com')`
```text
0 row(s) inserted, 1 replaced
```
`SELECT id, email, phone, verified FROM users`
| id (LONG) | email (STRING) | phone (STRING) | verified (BOOL) |
|---|---|---|---|
| 1 | ram.sharma@example.com | NULL | false |
(1 row)
## INSERT OR IGNORE
Syntax:
```sql
INSERT OR IGNORE INTO table [(column, ...)] VALUES (...), ... [RETURNING ...]
```
Adds rows with new keys, and **skips** rows whose key is already there. The old row stays as it is.
- **Returns:** `N row(s) inserted, M skipped (already there)`.
- **Notes:** use it when the first value must win: an imported trade list where rows never change, or "create the settings row if it is missing". `RETURNING` does not return skipped rows. PostgreSQL writes `ON CONFLICT DO NOTHING`.
**Example: Load a trade log file again: old trades stay, new ones are added**
Setup:
```sql
CREATE TABLE trade_log (date TIMESTAMP ROUND 1d, transaction_id LONG, symbol SYMBOL, quantity LONG, rate DOUBLE) TIMESERIES(date) PARTITION BY DAY DEDUP KEY (date, transaction_id);
INSERT INTO trade_log VALUES ('2026-09-10', 100009, 'COAST', 50, 400.0);
```
```sql
INSERT OR IGNORE INTO trade_log VALUES
('2026-09-10', 100009, 'COAST', 1, 1.0),
('2026-09-10', 100010, 'CITY', 50, 517.0);
SELECT transaction_id, symbol, quantity, rate FROM trade_log ORDER BY transaction_id;
```
Result:
`INSERT OR IGNORE INTO trade_log VALUES ('2026-09-10', 100009, 'COAST', 1, 1.0), ('2026-09-10', 10001`
```text
1 row(s) inserted, 1 skipped (already there)
```
`SELECT transaction_id, symbol, quantity, rate FROM trade_log ORDER BY transaction_id`
| transaction_id (LONG) | symbol (SYMBOL) | quantity (LONG) | rate (DOUBLE) |
|---|---|---|---|
| 100009 | COAST | 50 | 400 |
| 100010 | CITY | 50 | 517 |
(2 rows)
## RETURNING
Syntax:
```sql
INSERT ... VALUES (...) RETURNING expression [[AS] alias], ... | *
```
Gives the inserted rows back, like a `SELECT`, with `AUTO` ids and `DEFAULT` values filled in. Your app gets the new id in the same statement.
| Part | Meaning |
|---|---|
| `*` | all columns, computed columns included |
| `expression [AS alias]` | columns of the new row, or expressions over them (`upper(email)`) |
- **Returns:** the rows, and the message `N row(s) inserted`.
- **Notes:** also works with `UPDATE` and `DELETE` ([update and delete](update-delete.md#returning)). With `INSERT OR IGNORE`, skipped rows are not returned. With `INSERT OR REPLACE`, every written row is returned, also one that a later row of the same statement replaced. If the `RETURNING` list has an error (an unknown column), nothing is written.
**Example: Register a user and get the new id**
Setup:
```sql
CREATE TABLE users (id LONG PRIMARY KEY AUTO, email STRING NOT NULL UNIQUE, created TIMESTAMP DEFAULT now());
INSERT INTO users (email) VALUES ('ram@example.com');
```
```sql
INSERT INTO users (email) VALUES ('sita@example.com') RETURNING id, email;
```
Result:
| id (LONG) | email (STRING) |
|---|---|
| 2 | sita@example.com |
(1 row)
```text
1 row(s) inserted
```
**Example: Open accounts and see the defaults**
Setup:
```sql
CREATE TABLE accounts (id INT PRIMARY KEY AUTO, user_id LONG NOT NULL, balance DECIMAL(2) NOT NULL DEFAULT 0, status STRING DEFAULT 'active');
```
```sql
INSERT INTO accounts (user_id, balance) VALUES (1, 1000);
INSERT INTO accounts (user_id) VALUES (2) RETURNING *;
```
Result:
`INSERT INTO accounts (user_id, balance) VALUES (1, 1000)`
```text
1 row(s) inserted
```
`INSERT INTO accounts (user_id) VALUES (2) RETURNING *`
| id (INT) | user_id (LONG) | balance (DECIMAL(2)) | status (STRING) |
|---|---|---|---|
| 2 | 2 | 0.00 | active |
(1 row)
```text
1 row(s) inserted
```
**Example: A mistake in RETURNING: nothing is written** (shows an error)
Setup:
```sql
CREATE TABLE users (id LONG PRIMARY KEY AUTO, email STRING NOT NULL UNIQUE);
```
```sql
INSERT INTO users (email) VALUES ('ram@example.com') RETURNING user_id;
SELECT count(*) FROM users;
```
Result:
Error: `RETURNING: line 1, column 8: unknown column 'user_id' (table 'users' has: id email)`
## Times and time zones in values
Syntax:
```sql
'YYYY-MM-DD[T| ]HH:MM[:SS[.fff]][Z | +HH:MM | +HHMM | -HH:MM]'
```
KDB keeps every `TIMESTAMP` in **UTC**. The text you write is read like this:
| You write | KDB reads it as |
|---|---|
| `'2026-09-10T11:00:00+05:45'` | 11:00 Nepal time = 05:15 UTC |
| `'2026-09-10 11:00:00'` (no zone) | 11:00 **UTC** |
| `'2026-09-10T05:15:00Z'` | 05:15 UTC |
| `'2026-09-10'` | 00:00 UTC |
| `1789017300000` | milliseconds since 1970-01-01 UTC |
- A local time needs its zone offset, for example Nepal **+05:45** or New York `-04:00`. In the sample data the market opens at 11:00 local time (+05:45) = 05:15 UTC.
- **Notes:** the most common mistake is a local time **without** its zone offset (for example `+05:45`): the row is then 5 hours 45 minutes wrong. The date must be `YYYY-MM-DD` with two digits for month and day. More in [time and time zones](time-and-time-zones.md) and [TIMESTAMP](column-types.md#timestamp).
**Example: Live prices written in Nepal time**
Setup:
```sql
CREATE TABLE live_data (timestamp TIMESTAMP, symbol SYMBOL, ltp DOUBLE) TIMESERIES(timestamp) DEDUP KEY (timestamp, symbol);
```
```sql
INSERT INTO live_data VALUES
('2026-09-10T11:05:00+05:45', 'CITY', 513.0),
('2026-09-10T14:59:00+05:45', 'CITY', 527.1);
SELECT timestamp, symbol, ltp FROM live_data;
```
Result:
`INSERT INTO live_data VALUES ('2026-09-10T11:05:00+05:45', 'CITY', 513.0), ('2026-09-10T14:59:00+05:`
```text
2 row(s) inserted
```
`SELECT timestamp, symbol, ltp FROM live_data`
| timestamp (TIMESTAMP) | symbol (SYMBOL) | ltp (DOUBLE) |
|---|---|---|
| 2026-09-10T05:20:00.000Z | CITY | 513 |
| 2026-09-10T09:14:00.000Z | CITY | 527.1 |
(2 rows)
**Example: Nepal time without +05:45 is stored 5:45 hours too late**
Setup:
```sql
CREATE TABLE live_data (timestamp TIMESTAMP, symbol SYMBOL, ltp DOUBLE) TIMESERIES(timestamp);
```
```sql
INSERT INTO live_data VALUES ('2026-09-10 11:05:00', 'CITY', 513.0);
SELECT timestamp, symbol, ltp FROM live_data;
```
Result:
`INSERT INTO live_data VALUES ('2026-09-10 11:05:00', 'CITY', 513.0)`
```text
1 row(s) inserted
```
`SELECT timestamp, symbol, ltp FROM live_data`
| timestamp (TIMESTAMP) | symbol (SYMBOL) | ltp (DOUBLE) |
|---|---|---|
| 2026-09-10T11:05:00.000Z | CITY | 513 |
(1 row)
**Example: A date in the wrong format** (shows an error)
Setup:
```sql
CREATE TABLE live_data (timestamp TIMESTAMP, symbol SYMBOL, ltp DOUBLE) TIMESERIES(timestamp);
```
```sql
INSERT INTO live_data VALUES ('10/09/2026 11:05', 'CITY', 513.0);
```
Result:
Error: `column 'timestamp' (TIMESTAMP): '10/09/2026 11:05' is not a time like '2026-09-11 11:00:00' (UTC) or '2026-09-11T11:00:00+05:45'`
## INSERT ... SELECT, DEFAULT VALUES
Syntax:
```sql
INSERT INTO table [(columns)] SELECT ...
INSERT INTO table DEFAULT VALUES
```
Copies the rows of a query into a table in one statement.
- **Returns:** the number of rows written. `RETURNING` works too.
- **Notes:**
- The query must give one value for each target column, in order.
- `ON CONFLICT`, `OR REPLACE` and `OR IGNORE` work with it (see [ON CONFLICT](#on-conflict)).
- `DEFAULT VALUES` writes one row with every column at its `DEFAULT` (or NULL).
- **Big copies:** when the query is a plain read of tables (a `WHERE` and expressions are fine, `UNION ALL` too; no `GROUP BY`, `DISTINCT`, window or `LIMIT`), the rows go into the table while they are read. An `ORDER BY` of one table is fine too: the rows are sorted in batches of a few MB that go to temporary files in `_tmp/` of the database folder (`max_spill_bytes`), then written in that order - the same rows as a sort in memory, so the `ORDER BY` still decides which of two rows with one key is kept. They are not held first as a query result, so `--max-result-memory` does not stop the copy; the rows count toward the [transaction limits](limits.md) instead. It is still one statement: a bad row writes nothing, and the error names the row (`row 17000: ...`). This needs no `RETURNING`, no trigger on the table, and an `ON CONFLICT` only as `DO NOTHING` or a `DO UPDATE` that sets every other column from `EXCLUDED`. Any other query keeps its rows first, as before.
- **Into a table without a key** (no `DEDUP KEY`, no `PRIMARY KEY`, not split by `SYMBOL(...)`), such a copy is written as columns, the way [/imp](http-import.md) writes: about 50 bytes a row in memory until the next checkpoint, not about 600. A loop copying 660k rows at a time stays near 80 MB of unsaved rows, not 750 MB.
- **Into empty partitions of a table with a key** (a `DEDUP KEY` or `PRIMARY KEY` that is the whole `SORT BY`): when no partition the rows go to held a row before (a new month, or partitions a `DELETE` of the same transaction emptied) and the rows come in key order - as a copy from a table with the same `SORT BY` gives them - such a copy is written as columns too, and no key is looked up. A plain copy of every column, `INSERT INTO t SELECT a, b, c FROM s`, where `s` has the same columns and types, the same `SORT BY` and `PARTITION BY`, copies the partitions of `s` whole. The rows and the messages are the same as row by row: a key that repeats, or one already stored, sends the copy the row way, which reports it as before. `KDB_NO_INSERT_KEY_RUNS=1` and `KDB_NO_INSERT_COPY=1` turn these off.
- A subquery can also be a value: `VALUES ((SELECT max(id) FROM t) + 1, 'x')`.
**Example: Copy the closes of one symbol into another table**
Setup:
```sql
CREATE TABLE ochl_day (time TIMESTAMP, symbol SYMBOL, close DOUBLE) TIMESERIES(time);
CREATE TABLE city_close (time TIMESTAMP, close DOUBLE) TIMESERIES(time);
INSERT INTO ochl_day VALUES ('2026-09-09T09:00:00Z', 'CITY', 510), ('2026-09-10T09:00:00Z', 'CITY', 513), ('2026-09-10T09:00:00Z', 'COAST', 900);
```
```sql
INSERT INTO city_close SELECT time, close FROM ochl_day WHERE symbol = 'CITY';
SELECT * FROM city_close ORDER BY time;
```
Result:
`INSERT INTO city_close SELECT time, close FROM ochl_day WHERE symbol = 'CITY'`
```text
2 row(s) inserted
```
`SELECT * FROM city_close ORDER BY time`
| time (TIMESTAMP) | close (DOUBLE) |
|---|---|
| 2026-09-09T09:00:00.000Z | 510 |
| 2026-09-10T09:00:00.000Z | 513 |
(2 rows)
## ON CONFLICT (PostgreSQL upsert)
Syntax:
```sql
INSERT INTO table (columns) VALUES (...) | SELECT ...
ON CONFLICT [(key columns) [WHERE condition] | ON CONSTRAINT name]
DO NOTHING
| DO UPDATE SET column = EXCLUDED.column, ... [WHERE condition]
[RETURNING ..., (xmax = 0) AS inserted]
```
What to do when a new row has the same key as a row that is there.
- **DO NOTHING:** skip that row.
- **DO UPDATE:** change the row that is there. `EXCLUDED.column` is the value of the new row; `table.column` is the value that is there now.
- **Key columns:** the primary key, a `DEDUP KEY`, a `UNIQUE` column or constraint, or a `UNIQUE INDEX` with exactly these columns. `DO UPDATE` needs them; `DO NOTHING` without them means any unique key.
- **Partial unique index** (`CREATE UNIQUE INDEX ... WHERE ...`): `DO NOTHING` without key columns skips a clash in it too. To name it, write its columns and a `WHERE` that holds every `AND`-ed part of the index's own `WHERE` (in any order; more parts are fine). A `WHERE` that does not is an error, as in PostgreSQL.
- **RETURNING:** the rows that were inserted or updated. `xmax` is 0 for an inserted row and 1 for an updated one.
- **NULL:** a key with a NULL never clashes.
- **Notes:**
- Two new rows with the same key in one `DO UPDATE` statement are an error (as in PostgreSQL).
- `DO UPDATE SET` of every other column from `EXCLUDED` is as fast as `INSERT OR REPLACE`.
- Write the table name before a column (`t.hits + 1`); a plain name is ambiguous in PostgreSQL.
**Example: Count visits per page: insert the first, update the rest**
Setup:
```sql
CREATE TABLE page_hits (page STRING PRIMARY KEY, hits LONG NOT NULL);
INSERT INTO page_hits VALUES ('/chart', 1);
```
```sql
INSERT INTO page_hits (page, hits) VALUES ('/chart', 1), ('/news', 1)
ON CONFLICT (page) DO UPDATE SET hits = page_hits.hits + EXCLUDED.hits
RETURNING page, hits, (xmax = 0) AS inserted;
```
Result:
| page (STRING) | hits (LONG) | inserted (BOOL) |
|---|---|---|
| /chart | 2 | false |
| /news | 1 | true |
(2 rows)
```text
1 row(s) inserted, 1 updated
```
**Example: One open job per key: a partial unique index**
Setup:
```sql
CREATE TABLE jobs (id INT, dedup_key STRING, status STRING);
CREATE UNIQUE INDEX jobs_open ON jobs (dedup_key) WHERE status <> 'done';
INSERT INTO jobs VALUES (1, 'report', 'queued');
```
```sql
INSERT INTO jobs VALUES (2, 'report', 'queued') ON CONFLICT DO NOTHING;
INSERT INTO jobs VALUES (3, 'report', 'queued') ON CONFLICT (dedup_key) WHERE status <> 'done' DO NOTHING;
SELECT id, status FROM jobs;
```
Result:
`INSERT INTO jobs VALUES (2, 'report', 'queued') ON CONFLICT DO NOTHING`
```text
0 row(s) inserted, 1 skipped (already there)
```
`INSERT INTO jobs VALUES (3, 'report', 'queued') ON CONFLICT (dedup_key) WHERE status != 'done' DO NO`
```text
0 row(s) inserted, 1 skipped (already there)
```
`SELECT id, status FROM jobs`
| id (INT) | status (STRING) |
|---|---|
| 1 | queued |
(1 row)