CSV import over HTTP (POST /imp)
Send a CSV file to kdb serve and it loads the rows, making the table from the file when it is new - QuestDB's /imp.
- Category: HTTP API
- Keywords: /imp, imp, CSV import, timings, Server-Timing, parse_ms, lock_wait_ms, wal_ms, apply_ms, durable_ms, durable, queued, gzip upload, import CSV, upload CSV, load a file, QuestDB import, multipart, form-data, overwrite, atomicity, skipCol, skipRow, abort, forceHeader, partitionBy, delimiter, fmt, rowsImported, rowsRejected, schema
- See also: Line protocol (POST /write), Loading many rows, Run SQL over HTTP (/exec, /exp), HTTP API overview, CREATE TABLE, Partitions, SORT BY and DEDUP KEY
POST /imp loads a CSV file into a table. It is QuestDB's import route, so a QuestDB script or the QuestDB web console can send here without changes.
- A table that is not there yet is made from the file: the column names come from the first line, the types from the values.
- A table that is there gets the rows added (upserted, like the line protocol).
- Everything is written in one transaction: after a good answer all rows are in, after an error nothing changed.
For SQL, use POST /exec. For a fast live feed, use the line protocol. More ways to load data: loading data.
POST /imp
Syntax:
POST /imp?name=<table>[&db=<name>]
[&overwrite=true][&atomicity=skipCol|skipRow|abort]
[×tamp=<column>&partitionBy=DAY|MONTH|YEAR|NONE]
[&delimiter=,][&forceHeader=true][&fmt=json]
body: the CSV itself, or multipart/form-data with a file part named "data"
Reads the CSV, makes the table when it is new, and writes the rows.
| Parameter | Where | Meaning | Default |
|---|---|---|---|
name | URL | The table to load into. Without it, the file name of the upload (without .csv) is used. | required |
db | URL | The database (or the header X-KDB-Database). Without it: the one database that already has the table (which database). | see the left |
overwrite | URL | true: drop the table first and make it again from the file. | false |
atomicity | URL | What a value that does not fit its column does: skipCol the value becomes NULL, skipRow the whole row is left out, abort nothing at all is written. | skipCol |
dedup | URL | skip: no duplicate lookups in days that already hold rows. A key already stored may then be there twice; the answer has dedupSkipped (dedup=skip). | on |
timestamp | URL | Which column is the time column of a new table (TIMESERIES(...)). | none |
partitionBy | URL | NONE, DAY, MONTH or YEAR for a new table. Anything but NONE needs timestamp=. | NONE |
delimiter | URL | The character between the values; \t or tab for a tab. Without it KDB guesses from the first lines. | guessed |
forceHeader | URL | true: the first line is column names. Without it KDB decides: names that match the table, or a first line with nothing that looks like data. | guessed |
fmt | URL | json: answer as JSON. Anything else: a short text answer. | text |
schema | form part | JSON that sets column types: [{"name": "close", "type": "DOUBLE"}]. | types guessed |
Answer (with fmt=json) | Meaning |
|---|---|
status | "OK", or the error message |
database | the database the rows went into |
location | the table the rows went into |
rowsImported | how many rows were written |
rowsRejected | how many rows were left out (atomicity=skipRow) |
header | whether the first line was read as column names |
partitionBy, timestamp | the table's partitioning and time column |
columns | one entry per column: name, type, size in bytes, errors (values that did not fit) |
timings | where the time of the request went, in milliseconds (timings) |
- Status codes: 200 loaded (read
rowsRejected: a 200 can still have left rows out); 400 a bad CSV, a bad parameter, an unknown column,atomicity=abortwith a bad value, or nodbwhen the database cannot be worked out; 401/403 login or role (403 also when a table right does not allow inserts into this table); 404 unknown database; 405 for aGET; 408 a trigger ran past the statement timeout; 413 body over 64 MB (counted after unpacking); 500 server trouble (nothing written); 503 while the server is stopping; 507 the disk is full: free space, then send the same file again, unless the message saysdo not send it again(the disk filled while the log was flushed: the rows are already in the table and are saved once space is back, see backup and safety). - Roles: loading into a table that is there needs the write role. Making a table (a new name, or
overwrite=true) needs admin. - Types of a new table:
BOOLEAN,LONG,DOUBLE,DATE,TIMESTAMP, elseSTRING. Sendschema(or make the table yourself with CREATE TABLE) when you wantSYMBOL,DECIMAL(2)or aPRIMARY KEY. - Notes:
- Rows are upserted: a row with the same
PRIMARY KEYorDEDUP KEYreplaces the old one, so sending the same file twice makes no duplicates. - The first 5000 lines are read first, to find the header and the types. A column whose later values do not fit follows
atomicity. - The body may be compressed with
Content-Encoding: gzipordeflate. The 64 MB limit counts the unpacked CSV. A damaged body gives 400 and imports nothing. With curl:gzip -c prices.csv | curl --data-binary @- -H "Content-Encoding: gzip" "http://127.0.0.1:9900/imp?name=prices". - Triggers run once for the whole request, like the line protocol. Their time counts toward the statement timeout; send
X-KDB-Statement-Timeoutfor a longer one (admins only). A trigger that runs too long gives status 408 andtrigger <name>: statement timeout (<N> s) (nothing was imported).
- Rows are upserted: a row with the same
When the OK comes, and where the time went
An OK from /imp (200) or /write (204) means the rows are written and safe on disk. Nothing is queued for later: the answer waits for the log's disk sync. A server crash right after the OK loses none of these rows.
Each answer says where its time went. /imp with fmt=json has a timings object; /write answers 204 with no body, so it sends the same numbers in the standard Server-Timing header (browser developer tools show it). All are milliseconds with 3 decimals.
| Field | Server-Timing | What the time was spent on |
|---|---|---|
parse_ms | parse | reading the body (after unpacking gzip) and building the rows, before any lock |
lock_wait_ms | lock | waiting behind other writes of the same tables, or for a transaction that holds one |
wal_ms | wal | writing the log and waiting for its disk sync. Writers that arrive together share one sync |
apply_ms | apply | checking the rows (keys, rules) and putting them in the table |
trigger_ms | trigger | triggers the rows fired inside the request |
durable_ms | from the start until the rows were on disk: the moment the OK can go out | |
total_ms | total | the whole request |
The five steps add up to total_ms. A slow load then shows at a glance what to fix:
- a large
lock_wait_ms: other writers or a long transaction hold the table. Send fewer, bigger requests, or write to separate tables. - a large
wal_ms: the disk is slow to sync. Bigger requests share one sync between more rows. - a large
parse_ms: the body is big. Several senders at once parse in parallel.
The activity log (GET /api/activity) keeps the same numbers for each write event, and GET /api/stats adds them up since the start under writes (line_protocol and csv_import: requests, rows and the sum of each step; a step's average is its sum divided by requests). bench/parallel-write.mjs sends from 1, 2, 4 and 8 senders at once and prints rows per second with the average of each step.
Which database, without db=
A QuestDB client has no db= to send, so /imp works out the database from the table name:
| The server has | Without db= the rows go to |
|---|---|
| one database | that database |
| several, and one of them has the table | that database (the default one wins when it is among several that have it) |
| several, and more than one has the table (not the default) | nothing: 400, add db= |
| several, and none has the table | nothing: 400, add db= or create the table first |
KDB refuses rather than guessing, so a load meant for another database never makes a new table with guessed types in the default one. The answer's database says where the rows went.
HTTP: A new table straight from a CSV of daily closes
POST /imp?name=day_closes×tamp=time&partitionBy=YEAR&fmt=json
Content-Type: text/csv
time,symbol,close
2026-09-09T00:00:00Z,CITY,502.10
2026-09-09T00:00:00Z,COAST,374.80
2026-09-10T00:00:00Z,CITY,504.90
2026-09-10T00:00:00Z,COAST,376.10
###
GET /exec?compat=0&query=SELECT * FROM day_closes ORDER BY time, symbol
Response:
POST /imp?name=day_closes×tamp=time&partitionBy=YEAR&fmt=json
HTTP 200 (application/json)
{
"status": "OK",
"database": "demo",
"location": "day_closes",
"rowsRejected": 0,
"rowsImported": 4,
"header": true,
"partitionBy": "YEAR",
"timestamp": "time",
"columns": [
{"name": "time", "type": "TIMESTAMP", "size": 8, "errors": 0},
{"name": "symbol", "type": "STRING", "size": 0, "errors": 0},
{"name": "close", "type": "DOUBLE", "size": 8, "errors": 0}
],
"timings": {
"parse_ms": 22.162,
"lock_wait_ms": 0.012,
"wal_ms": 5.798,
"apply_ms": 6.818,
"trigger_ms": 0,
"durable_ms": 34.79,
"total_ms": 34.79
}
}
GET /exec?compat=0&query=SELECT * FROM day_closes ORDER BY time, symbol
HTTP 200 (application/json)
{
"query": "SELECT * FROM day_closes ORDER BY time, symbol",
"results": [
{
"statement": "SELECT * FROM day_closes ORDER BY time, symbol",
"columns": [
{"name": "time", "type": "TIMESTAMP"},
{"name": "symbol", "type": "STRING"},
{"name": "close", "type": "DOUBLE"}
],
"dataset": [
["2026-09-09T00:00:00.000Z", "CITY", 502.1],
["2026-09-09T00:00:00.000Z", "COAST", 374.8],
["2026-09-10T00:00:00.000Z", "CITY", 504.9],
["2026-09-10T00:00:00.000Z", "COAST", 376.1]
],
"count": 4,
"truncated": false,
"rows_affected": 0,
"message": "",
"timings": {"parse_us": 31, "execute_us": 87}
}
],
"timings": {"parse_us": 31, "execute_us": 87}
}
HTTP: Add rows to a table that already exists (sample data: sample)
POST /imp?name=securities&fmt=json
symbol,name,sector
SUMMIT,Summit Bank Limited,Banking
###
GET /exec?compat=0&query=SELECT symbol, name FROM securities ORDER BY symbol
Response:
POST /imp?name=securities&fmt=json
HTTP 200 (application/json)
{
"status": "OK",
"database": "demo",
"location": "securities",
"rowsRejected": 0,
"rowsImported": 1,
"header": true,
"partitionBy": "NONE",
"columns": [
{"name": "symbol", "type": "SYMBOL", "size": 0, "errors": 0},
{"name": "name", "type": "STRING", "size": 0, "errors": 0},
{"name": "sector", "type": "STRING", "size": 0, "errors": 0}
],
"timings": {
"parse_ms": 0.164,
"lock_wait_ms": 0.012,
"wal_ms": 3.957,
"apply_ms": 3.514,
"trigger_ms": 0,
"durable_ms": 7.647,
"total_ms": 7.647
}
}
GET /exec?compat=0&query=SELECT symbol, name FROM securities ORDER BY symbol
HTTP 200 (application/json)
{
"query": "SELECT symbol, name FROM securities ORDER BY symbol",
"results": [
{
"statement": "SELECT symbol, name FROM securities ORDER BY symbol",
"columns": [
{"name": "symbol", "type": "SYMBOL"},
{"name": "name", "type": "STRING"}
],
"dataset": [
["ACME", "Acme Investment Company"],
["CITY", "City Bank Limited"],
["COAST", "Coastal Bank Limited"],
["SUMMIT", "Summit Bank Limited"],
["TELCO", "Northwind Telecom Limited"],
["VOLT", "Volt River Hydropower Limited"]
],
"count": 6,
"truncated": false,
"rows_affected": 0,
"message": "",
"timings": {"parse_us": 13, "execute_us": 193}
}
],
"timings": {"parse_us": 13, "execute_us": 193}
}
HTTP: The short text answer (no fmt=json)
POST /imp?name=broker_list
id,name
58,Northgate Securities
45,Sani Securities
Response:
HTTP 200 (text/plain)
Database: demo
Location: broker_list
Rows handled: 2
Rows imported: 2
0 | id | LONG | 0 errors
1 | name | STRING | 0 errors
HTTP: A bad value: skipCol (the default) makes it NULL and keeps the row
Setup:
CREATE TABLE day_closes2 (symbol SYMBOL PRIMARY KEY, close DOUBLE);
POST /imp?name=day_closes2&fmt=json
symbol,close
CITY,504.90
COAST,not a number
###
GET /exec?compat=0&query=SELECT * FROM day_closes2 ORDER BY symbol
Response:
POST /imp?name=day_closes2&fmt=json
HTTP 200 (application/json)
{
"status": "OK",
"database": "demo",
"location": "day_closes2",
"rowsRejected": 0,
"rowsImported": 2,
"header": true,
"partitionBy": "NONE",
"columns": [
{"name": "symbol", "type": "SYMBOL", "size": 0, "errors": 0},
{"name": "close", "type": "DOUBLE", "size": 8, "errors": 1}
],
"timings": {
"parse_ms": 0.063,
"lock_wait_ms": 0.014,
"wal_ms": 1.918,
"apply_ms": 5.278,
"trigger_ms": 0,
"durable_ms": 7.273,
"total_ms": 7.273
}
}
GET /exec?compat=0&query=SELECT * FROM day_closes2 ORDER BY symbol
HTTP 200 (application/json)
{
"query": "SELECT * FROM day_closes2 ORDER BY symbol",
"results": [
{
"statement": "SELECT * FROM day_closes2 ORDER BY symbol",
"columns": [
{"name": "symbol", "type": "SYMBOL"},
{"name": "close", "type": "DOUBLE"}
],
"dataset": [
["CITY", 504.9],
["COAST", null]
],
"count": 2,
"truncated": false,
"rows_affected": 0,
"message": "",
"timings": {"parse_us": 19, "execute_us": 105}
}
],
"timings": {"parse_us": 19, "execute_us": 105}
}
HTTP: skipRow leaves the bad row out
Setup:
CREATE TABLE day_closes3 (symbol SYMBOL PRIMARY KEY, close DOUBLE);
POST /imp?name=day_closes3&atomicity=skipRow&fmt=json
symbol,close
CITY,504.90
COAST,not a number
###
GET /exec?compat=0&query=SELECT * FROM day_closes3 ORDER BY symbol
Response:
POST /imp?name=day_closes3&atomicity=skipRow&fmt=json
HTTP 200 (application/json)
{
"status": "OK",
"database": "demo",
"location": "day_closes3",
"rowsRejected": 1,
"rowsImported": 1,
"header": true,
"partitionBy": "NONE",
"columns": [
{"name": "symbol", "type": "SYMBOL", "size": 0, "errors": 0},
{"name": "close", "type": "DOUBLE", "size": 8, "errors": 1}
],
"timings": {
"parse_ms": 0.07,
"lock_wait_ms": 0.012,
"wal_ms": 1.845,
"apply_ms": 3.957,
"trigger_ms": 0,
"durable_ms": 5.884,
"total_ms": 5.884
}
}
GET /exec?compat=0&query=SELECT * FROM day_closes3 ORDER BY symbol
HTTP 200 (application/json)
{
"query": "SELECT * FROM day_closes3 ORDER BY symbol",
"results": [
{
"statement": "SELECT * FROM day_closes3 ORDER BY symbol",
"columns": [
{"name": "symbol", "type": "SYMBOL"},
{"name": "close", "type": "DOUBLE"}
],
"dataset": [
["CITY", 504.9]
],
"count": 1,
"truncated": false,
"rows_affected": 0,
"message": "",
"timings": {"parse_us": 17, "execute_us": 100}
}
],
"timings": {"parse_us": 17, "execute_us": 100}
}
HTTP: atomicity=abort: one bad value and nothing is written
Setup:
CREATE TABLE day_closes4 (symbol SYMBOL PRIMARY KEY, close DOUBLE);
POST /imp?name=day_closes4&atomicity=abort&fmt=json
symbol,close
CITY,504.90
COAST,not a number
Response:
HTTP 400 (application/json)
{
"status": "line 3: column 'close': 'not a number' is not a number (atomicity=abort: nothing was imported)"
}
HTTP: A tab-separated file (sample data: sample)
POST /imp?name=broker_notes&delimiter=tab&fmt=json
id note
58 top buyer of the day
45 sold CITY
Response:
HTTP 200 (application/json)
{
"status": "OK",
"database": "demo",
"location": "broker_notes",
"rowsRejected": 0,
"rowsImported": 2,
"header": true,
"partitionBy": "NONE",
"columns": [
{"name": "id", "type": "LONG", "size": 8, "errors": 0},
{"name": "note", "type": "STRING", "size": 0, "errors": 0}
],
"timings": {
"parse_ms": 14.877,
"lock_wait_ms": 0.013,
"wal_ms": 3.195,
"apply_ms": 3.204,
"trigger_ms": 0,
"durable_ms": 21.289,
"total_ms": 21.289
}
}
HTTP: The table must be named
POST /imp
symbol,close
CITY,504.90
Response:
HTTP 400 (text/plain)
name the table: POST /imp?name=table (or send a file with a name)
HTTP: Importing needs POST
GET /imp?name=day_closes
Response:
HTTP 405 (application/json)
{"error": "method GET is not allowed here"}
Clients (not run by the help build):
# QuestDB's own way: a multipart upload with a file part named "data".
curl.exe -s -F "data=@day_closes.csv" `
"http://127.0.0.1:9900/imp?name=day_closes×tamp=time&partitionBy=YEAR&fmt=json&db=market"
import requests
with open("day_closes.csv", "rb") as f:
r = requests.post("http://127.0.0.1:9900/imp",
params={"name": "day_closes", "timestamp": "time",
"partitionBy": "YEAR", "fmt": "json", "db": "market"},
files={"data": ("day_closes.csv", f, "text/csv")})
answer = r.json()
if answer["status"] != "OK":
raise RuntimeError(answer["status"])
print(answer["rowsImported"], "rows in", answer["location"])