Push Data
You can push data into Hotdata through the API. This page covers the loading model, the upload sequence, and the write modes for publishing to a table; for data Hotdata pulls from systems you connect, see Pull Data.
The loading model
Teams arriving from a relational database generally expect CREATE TABLE followed by repeated INSERT statements. Hotdata does not work that way.
There is no DDL to run and no transactional insert path — SQL here is read-only. Data is loaded by publishing it: a file is supplied, and it becomes the contents of a table. Tables are created on first publish, so no migration step is required. Every publish is atomic — readers see either the previous contents or the new ones, never a partially written table.
The engine is optimised for bulk operations. A single call carrying ten million rows is the intended pattern; ten million single-row calls are not. Integrations that write row-at-a-time should batch upstream and publish at whatever interval freshness allows.
Every publish, regardless of size, goes through one endpoint:
POST /v1/databases/{database_id}/schemas/{schema}/tables/{table}/loads
The body carries a mode — how the incoming rows are applied to those already present, covered in the next section — and exactly one source. More than one source, or an unrecognised field name, is rejected with a 400 rather than silently ignored.
| Source | Description | Formats | Ceiling |
|---|---|---|---|
upload_id | A file already placed in storage through an upload session. The primary path for anything of size. | Parquet, CSV, JSON (NDJSON) | 16 GiB per file |
data | CSV text inline in the request body, header row included. Types are inferred unless you declare columns. | CSV | 2 MiB |
result_id | A persisted query result, copied into a table so the data outlives the result. | — | — |
The upload sequence
File contents do not travel through the API: the bytes go straight to object storage, and the load names the file by id.
The clients already do this. upload_file opens the session, transfers the bytes — one PUT or many, concurrently — and finalizes, in one call:
upload = uploads_api.upload_file("orders.csv", content_type="text/csv")
databases_api.load_database_table(
database_id, "public", "orders",
hotdata.LoadManagedTableRequest(
mode="replace",
upload_id=upload.upload_id,
format="csv",
),
)
The Python and Rust clients both carry it, for Parquet, CSV, or NDJSON, at any size up to the 16 GiB per-file limit. From the command line, hotdata databases load --file orders.csv does the same. The rest of this section is the protocol they implement.
Writing your own client
Every upload is the same three steps, with a fourth call to publish what arrived:
- Request an upload session.
POST /v1/uploads— declare the approximate size, and the response returns an upload id, one or more URLs to write to, and a finalize token. - PUT the bytes to the URL. They are written directly to object storage, so a 12 GB transfer does not hold an API connection open for its duration.
- Finalize.
POST /v1/uploads/{upload_id}/finalizecommits the upload, after which the file can be loaded. - Load it. The endpoint above, with the
upload_idas its source.
Only the second step varies with file size: a single PUT or several. The choice need not be made in advance — open a session and read mode from the response.
POST /v1/uploads
{ "filename": "orders.csv", "declared_size_bytes": 10485760, "content_type": "text/csv" }
# => { "upload_id": "upld7k2m9q4rx8vt3wnb5cyzhdfjs0", "mode": "multipart",
# "part_size": 8388608, "part_urls": ["https://…", "https://…"],
# "headers": {}, "finalize_token": "…" }
PUT <part_urls[0]> # bytes [0, part_size); keep the response's ETag
PUT <part_urls[1]> # the remainder
POST /v1/uploads/upld7k2m9q4rx8vt3wnb5cyzhdfjs0/finalize
X-Upload-Finalize-Token: <finalize_token>
{ "parts": [{ "part_number": 1, "e_tag": "\"9f8c1e5b7a2d4f60b3c8e1a9d7f4b206\"" },
{ "part_number": 2, "e_tag": "\"4b2af0c73d1e5a89f60b2c4e8d1a7f35\"" }] }
POST /v1/databases/dbid6lguax1dxn9y1xj5gxnameyywl/schemas/public/tables/orders/loads
{ "mode": "replace", "upload_id": "upld7k2m9q4rx8vt3wnb5cyzhdfjs0", "format": "csv" }
A session that comes back single carries a url instead: PUT the whole file to it, and finalize with no body. format is optional — it is detected from the Content-Type and the contents — and overrides that detection.
Four things the clients handle, and a new one has to:
| Concern | What the clients do |
|---|---|
| Part concurrency | Cap in-flight PUTs — the clients allow 10, lowered further to keep buffered parts under 256 MiB. Uncapped, a large file's PUTs time out. |
| Stalled PUTs | A part PUT can accept bytes and then go silent. Neither a connect timeout nor an inactivity read timeout catches it, so a watchdog shuts the socket down. |
| Retries | Retry a failed part, and re-mint its URL on a 403. |
| Expiring part URLs | For a multipart session, mint each part URL just before its PUT, so a presigned URL cannot expire mid-transfer. |
A single file
For a file small enough to send in one request, the sequence is four calls and a single transfer.
400. Step 4 is deliberately a separate call: an upload can be held and loaded later, but consumed only once. Presigned URLs are valid for 30 minutes; the session itself for 24 hours.A large file
Above the threshold, the session returns as multipart with a list of part URLs. PUT the slices concurrently, retain every ETag, and submit the set at finalize. Where the total size is not known in advance — a stream, for instance — omit declared_size_bytes and mint part URLs as the transfer proceeds. The finalize call is unchanged, and no URL expires while waiting on a slow producer.
part_size, not by part count. Dividing the file evenly by the number of URLs supplied can produce an intermediate part below the 5 MiB minimum, and the error surfaces only when finalize rejects the set. The final part is the only one permitted to be short. A rejected set returns 4xx rather than 5xx and the session survives, so the parts can be corrected and finalize retried.Multiple files
Open all the sessions in a single call, then transfer them with whatever concurrency suits. Loading is the step that cannot be parallelised: a load takes a write lock on its target table, so a second concurrent load into the same table returns 409.
Small payloads
Below 2 MiB, the upload sequence adds more overhead than the payload warrants. CSV text can go directly in the load call's data field — one call, no session to finalize. This suits a few thousand rows of reference data, a lookup table, or the remainder of a stream flushed on a timer.
413 with the code INLINE_DATA_TOO_LARGE — branch on that code and fall back to the upload sequence rather than estimating the limit in advance. Inline data is CSV with a header row; declare columns to set types explicitly instead of having them inferred.Ingestion limits
The figures below apply across all of the paths above. The multipart threshold and the part size are the two figures that are not fixed, which is why the session response — not this table — is the authority on which mode applies and on how the bytes are sliced.
| Limit | Value | Notes |
|---|---|---|
Inline data | 2 MiB | Exceeding this returns 413 INLINE_DATA_TOO_LARGE |
| Maximum upload size | 16 GiB | Per file, whatever the format. Declare declared_size_bytes and an oversized file is refused at create, before any transfer |
| Single PUT ceiling | 5 GiB | Above this, multipart is mandatory |
| Part size | 5 MiB – 5 GiB | 8 MiB by default; use the value from the session |
| Parts per upload | 10,000 | Determines the smallest viable part size |
| Part URLs per mint call | 100 | Streaming uploads only |
| Sessions per batch call | 100 | Via POST /v1/uploads/batch |
| Presigned URL lifetime | 30 min | Re-mint rather than retrying a stale URL |
| Upload session lifetime | 24 h | Unfinalized sessions are removed |
Load modes
mode determines what happens to the rows already in the table. replace and append require nothing further; the remaining three require a key — the columns that identify a row — either declared on the table or supplied with the load.
| Mode | Effect on existing rows | Key required | Typical use |
|---|---|---|---|
replace | All are discarded; the payload becomes the table's contents. | — | Full snapshots, and the first load into any new table. |
append | Retained; the new rows are added alongside them. | — | Immutable events, or combining several files into one table. |
upsert | Matching rows are replaced; unmatched payload rows are inserted. | yes | Incremental synchronisation. Supply full rows. |
update | Matching rows are replaced. Payload rows with no match are ignored. | yes | Corrections that must not create rows. |
delete | Rows whose keys appear in the payload are removed. | yes | Propagating deletions. Supply keys only, not full rows. |
Constraints
The first load into a new table must be replace. Every other mode requires a table that already has a shape to modify, and requesting one against a never-loaded table returns a 400 stating as much. Whether append and the keyed modes are available at all also depends on how the instance is provisioned, so treat that 400 as a branch to handle rather than an assertion failure.
A key identifies rows for matching; it is not a uniqueness constraint. The key names the columns that match incoming rows against existing ones — the match upsert, update, and delete act on. Nothing verifies the key is unique, and duplicates are not rejected. It is resolved per load — from the request where supplied, otherwise from the table's declaration — so changing it between loads silently changes what the merge matches on. Keeping it stable for a given table is the caller's responsibility.
On replace and append, schema changes are accepted only where they are lossless. A column the table has not seen before is added. A type that widens without loss is promoted — an INTEGER column becomes BIGINT where the payload requires it. A narrowing or cross-family change — text to number, or a decimal whose scale has moved — is refused with a 409 before anything is written; the table remains as it was and remains queryable.
The keyed modes hold the table's types rather than widening them. upsert, update, and delete read the payload at the types the table already carries, so a payload column typed more widely than its target is narrowed to match; an INTEGER key sent as a BIGINT matches as expected. A value that does not fit the target column — an id beyond the range of an INTEGER, or text where a number is expected — is refused with a 400 naming the column, and nothing is written. Widening a column is therefore a replace, which carries the table's whole contents; it is not something a merge does on the caller's behalf.
Error handling
Errors are returned as { "error": { "message", "code" } }. Branch on the code, never the message. Every response — rejections included — carries an X-Trace-Id, which should be logged and quoted in support requests.
| Status | Code | Meaning | Action |
|---|---|---|---|
| 413 | INLINE_DATA_TOO_LARGE | Inline payload above 2 MiB. | Fall back to an upload session; this case is recoverable. |
| 501 | PRESIGN_UNSUPPORTED | This instance cannot issue upload URLs. | Send the data inline instead. |
| 409 | — | The table is busy, the upload is already being consumed, or an incompatible column type change was requested. | Retry after a short delay. Nothing was written. |
| 400 | BAD_REQUEST | Two sources supplied, a mode the table cannot accept, a keyed mode without a key, a value the target column cannot represent, malformed CSV, or — on POST /v1/uploads — a declared size above the maximum upload size. | Correct the request. Request-shape errors are caught before any job is created, and an oversized upload is refused before any bytes are transferred. |
| 404 | NOT_FOUND | Unknown database, or a table that has been deleted. | A deleted table is not recreated by a load; declare it again. |
| 429 | OVERLOADED | The instance is shedding load. | Honour Retry-After. |
Querying and result formats
One endpoint runs SQL. It responds in JSON with a preview of the rows and, for larger results, a result_id that can be retrieved in the format required.
POST /v1/query
X-Database-Id: dbid6lguax1dxn9y1xj5gxnameyywl
{ "sql": "SELECT status, count(*) FROM default.main.orders GROUP BY 1" }
# => { "query_run_id": …, "result_id": …, "columns": […], "rows": […], "truncated": false }
GET /v1/results/{result_id}?format=arrow # or parquet, csv, md, json
Use Arrow or Parquet for any significant volume; JSON is provided for convenience rather than throughput. Large results paginate with offset and limit and return a Link header for the next page; Parquet is returned whole.
Queries are read-only. INSERT, COPY, and DDL are refused for every caller — writing is the load endpoint's job.
The dialect is HotSQL. It is standard analytical SQL with additions for search and geospatial work; the full syntax and function reference is at /docs/sql.
Further reading
- Core Concepts — the object model and authentication.
- Pull Data — ingesting from sources you connect.
- API Reference — request and response shapes, field by field.
- SQL Reference — the HotSQL syntax and function reference.
- Python SDK — the Python client;
upload_fileimplements the upload sequence. - Rust SDK — the Rust client, with the same
upload_file.