# 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)](line-protocol.md), [Loading many rows](loading-data.md), [Run SQL over HTTP (/exec, /exp)](http-exec.md), [HTTP API overview](http-overview.md), [CREATE TABLE](create-table.md), [Partitions, SORT BY and DEDUP KEY](partitions-and-dedup.md)
`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](line-protocol.md#post-write)).
- Everything is written in **one transaction**: after a good answer all rows are in, after an error nothing changed.
For SQL, use [POST /exec](http-exec.md#post-exec). For a fast live feed, use the [line protocol](line-protocol.md). More ways to load data: [loading data](loading-data.md).
## POST /imp
Syntax:
```sql
POST /imp?name=
[&db=]
[&overwrite=true][&atomicity=skipCol|skipRow|abort]
[×tamp=&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](#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](partitions-and-dedup.md#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](#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=abort` with a bad value, or no `db` when the database cannot be worked out; 401/403 login or role (403 also when a [table right](table-rights.md) does not allow inserts into this table); 404 unknown database; 405 for a `GET`; 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 says `do 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](backup-and-safety.md)).
- **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`, else `STRING`. Send `schema` (or make the table yourself with [CREATE TABLE](create-table.md)) when you want `SYMBOL`, `DECIMAL(2)` or a `PRIMARY KEY`.
- **Notes:**
- Rows are **upserted**: a row with the same `PRIMARY KEY` or `DEDUP KEY` replaces 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: gzip` or `deflate`. 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](triggers.md) run once for the whole request, like the line protocol. Their time counts toward the [statement timeout](protection.md#statement-timeout); send `X-KDB-Statement-Timeout` for a longer one (admins only). A trigger that runs too long gives status 408 and `trigger : statement timeout ( s) (nothing was imported)`.
### When the OK comes, and where the time went
An OK from `/imp` (200) or [`/write`](line-protocol.md#post-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](triggers.md) 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**
```http
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)
```text
{
"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)
```text
{
"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)
```http
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)
```text
{
"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)
```text
{
"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)**
```http
POST /imp?name=broker_list
id,name
58,Northgate Securities
45,Sani Securities
```
Response:
HTTP 200 (text/plain)
```text
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:
```text
CREATE TABLE day_closes2 (symbol SYMBOL PRIMARY KEY, close DOUBLE);
```
```http
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)
```text
{
"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)
```text
{
"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:
```text
CREATE TABLE day_closes3 (symbol SYMBOL PRIMARY KEY, close DOUBLE);
```
```http
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)
```text
{
"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)
```text
{
"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:
```text
CREATE TABLE day_closes4 (symbol SYMBOL PRIMARY KEY, close DOUBLE);
```
```http
POST /imp?name=day_closes4&atomicity=abort&fmt=json
symbol,close
CITY,504.90
COAST,not a number
```
Response:
HTTP 400 (application/json)
```text
{
"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)
```http
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)
```text
{
"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**
```http
POST /imp
symbol,close
CITY,504.90
```
Response:
HTTP 400 (text/plain)
```text
name the table: POST /imp?name=table (or send a file with a name)
```
**HTTP: Importing needs POST**
```http
GET /imp?name=day_closes
```
Response:
HTTP 405 (application/json)
```text
{"error": "method GET is not allowed here"}
```
Clients (not run by the help build):
```powershell
# 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"
```
```python
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"])
```