KDB knowledge

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.

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.

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]
         [&timestamp=<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.

ParameterWhereMeaningDefault
nameURLThe table to load into. Without it, the file name of the upload (without .csv) is used.required
dbURLThe database (or the header X-KDB-Database). Without it: the one database that already has the table (which database).see the left
overwriteURLtrue: drop the table first and make it again from the file.false
atomicityURLWhat 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
dedupURLskip: 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
timestampURLWhich column is the time column of a new table (TIMESERIES(...)).none
partitionByURLNONE, DAY, MONTH or YEAR for a new table. Anything but NONE needs timestamp=.NONE
delimiterURLThe character between the values; \t or tab for a tab. Without it KDB guesses from the first lines.guessed
forceHeaderURLtrue: 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
fmtURLjson: answer as JSON. Anything else: a short text answer.text
schemaform partJSON that sets column types: [{"name": "close", "type": "DOUBLE"}].types guessed
Answer (with fmt=json)Meaning
status"OK", or the error message
databasethe database the rows went into
locationthe table the rows went into
rowsImportedhow many rows were written
rowsRejectedhow many rows were left out (atomicity=skipRow)
headerwhether the first line was read as column names
partitionBy, timestampthe table's partitioning and time column
columnsone entry per column: name, type, size in bytes, errors (values that did not fit)
timingswhere the time of the request went, in milliseconds (timings)

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.

FieldServer-TimingWhat the time was spent on
parse_msparsereading the body (after unpacking gzip) and building the rows, before any lock
lock_wait_mslockwaiting behind other writes of the same tables, or for a transaction that holds one
wal_mswalwriting the log and waiting for its disk sync. Writers that arrive together share one sync
apply_msapplychecking the rows (keys, rules) and putting them in the table
trigger_mstriggertriggers the rows fired inside the request
durable_msfrom the start until the rows were on disk: the moment the OK can go out
total_mstotalthe whole request

The five steps add up to total_ms. A slow load then shows at a glance what to fix:

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 hasWithout db= the rows go to
one databasethat database
several, and one of them has the tablethat 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 tablenothing: 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&timestamp=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&timestamp=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&timestamp=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"])