Skip to content
Talk to our solutions team

Worksheets

A worksheet is a time-bounded, in-memory analytics workspace held by one data.svc process for one tenant, backed by a private in-process DuckDB instance. You create it, register CSV or XLSX files against it, define tables over those files, profile the columns, and run SQL. When the TTL elapses the worksheet and everything in it is gone.

Worksheets bypass the entity engine entirely. There are no entity definitions, no hooks, no migrations, no history, no access rules, no row-level security and no field protection on a worksheet. The only enforced boundary is the tenant key. Anything durable belongs in a datastore or in Data Pipes.

create worksheet
→ register file(s) (multipart upload, or a remote URL)
→ default tables appear (one per CSV, one per XLSX sheet)
→ define tables (optional: union several files into one)
→ schema / sample / analytics / distinct
→ query (SQL)
→ delete, or let the TTL expire

Registering a file creates its tables immediately: the file is read once, at registration, into an in-memory table. Nothing is re-read later.

All paths are relative to the service; the gateway serves them under /data/. Every route requires a JWT and the four tenant routing headers — there is no anonymous mirror.

MethodPathPurpose
POST/worksheetsCreate a worksheet
GET/worksheets/{worksheet_id}Fetch worksheet state, files and tables
DELETE/worksheets/{worksheet_id}Destroy it and free its memory
POST/worksheets/{worksheet_id}/filesRegister a file (upload or URL)
GET/worksheets/{worksheet_id}/filesList registered files
GET/worksheets/{worksheet_id}/files/{file_id}Fetch one file’s metadata
DELETE/worksheets/{worksheet_id}/files/{file_id}Unregister a file and drop its tables
POST/worksheets/{worksheet_id}/tablesDefine a table over one or more files
GET/worksheets/{worksheet_id}/tablesList tables
DELETE/worksheets/{worksheet_id}/tables/{table_name}Drop a table
GET/worksheets/{worksheet_id}/tables/{table_name}/schemaColumn types plus per-column stats
GET/worksheets/{worksheet_id}/tables/{table_name}/sampleSample rows
GET/worksheets/{worksheet_id}/tables/{table_name}/analytics/{column_name}Profile one column
GET/worksheets/{worksheet_id}/tables/{table_name}/columns/{column_name}/distinctDistinct values with counts
POST/worksheets/{worksheet_id}/queryExecute SQL

file_id is the file’s logical_name. There is no separate identifier.

The tenant key is taken from the request context, never from the body, and is stored on the worksheet at creation. Every subsequent operation compares it: a mismatch is 403 worksheet.cpet_violation, not 404. A worksheet belonging to another tenant can therefore be distinguished from one that does not exist, but not read.

Authentication is the IAM token; roles are not consulted. Any authenticated principal in the tenant can create a worksheet, upload to it, and run arbitrary SQL inside it.

POST /worksheets
Content-Type: application/json
Idempotency-Key: 3f1c-q3-sales
{
"name": "Q3 sales analysis",
"ttl_seconds": 3600
}
FieldTypeRequiredNotes
namestringnoEchoed back; not used for lookup
ttl_secondsintnoDefault 1800. Outside 60–86400 is worksheet.invalid_ttl

A JSON body is required. An empty request body is 400 invalid_request.

{
"worksheet_id": "01HQZ...",
"name": "Q3 sales analysis",
"created_at": "2026-04-13T14:22:00Z",
"expires_at": "2026-04-13T15:22:00Z",
"ttl_seconds": 3600,
"status": "active",
"files": [],
"tables": []
}

status carries active or expired, but every response that reaches you comes from a live worksheet — an expired one answers 410 before a body is built — so in practice it always reads active. GET /worksheets/{worksheet_id} returns the same shape with files and tables populated.

The TTL is fixed at creation. No route extends it.

Idempotency-Key is honoured on this route only. A repeat with the same key from the same tenant inside 10 minutes replays the stored 201 body verbatim instead of creating a second worksheet. Keys are held per tenant and, like everything else here, in the process’s memory.

One route serves both ingestion paths and dispatches on Content-Type: multipart/form-data is treated as an upload, anything else is parsed as a JSON URL registration.

POST /worksheets/{worksheet_id}/files
Content-Type: multipart/form-data
Form fieldRequiredNotes
fileyesThe bytes
logical_nameyesIdentifier inside the worksheet; also the file_id
formatyescsv or xlsx
format_optionsnoA JSON string holding the options object below
{
"url": "https://example.com/data/sales_q3.csv",
"logical_name": "sales_q3",
"format": "csv",
"format_options": { "delimiter": ",", "header": true }
}

Here format_options is a real JSON object, not a string.

Two formats are accepted. Any other format value is file.format_invalid.

FormatTokenProduces
CSVcsvOne table
ExcelxlsxOne table per imported sheet
KeyApplies toTypeDefaultMeaning
delimitercsvstringauto-detectedField separator
headercsv, xlsxboolxlsx true; csv auto-detectedFirst row holds column names
null_stringcsvstringnoneLiteral treated as NULL
encodingstringAccepted by the request shape and never applied
sheetsxlsxstring[]emptySheets to import; empty triggers discovery
header_rowxlsxintauto-detected1-indexed header row; rows above are skipped

Everything else in a CSV — column names, types, quoting — is inferred by the engine’s CSV reader.

When sheets is omitted the service inspects the workbook and applies these rules in order:

  1. Any sheet whose declared dimensions exceed 1,000,000 rows or 1,000 columns fails the whole file with file.size_exceeded.
  2. Each sheet is scanned for a non-empty cell, bounded to the first 5,000 rows and 10 seconds. A sheet that exhausts either bound counts as having no data.
  3. Exactly one sheet with data — it is imported.
  4. More than one — file.sheet_selection_required, with available_sheets in the error details. Re-send with sheets naming the ones you want.
  5. None — file.format_invalid.

Naming sheets explicitly skips discovery — including the dimension check — and imports exactly those sheets. Each becomes its own table. A sheet that loads but turns out to be entirely null or blank is dropped silently; if every sheet drops out, the file fails with file.format_invalid.

When header_row is null the header is detected by taking the modal non-empty-cell count over rows 2 to 20 and picking the first row that reaches it — the common case of title and banner rows above the real header. Detection needs at least two rows agreeing on a width of at least two columns; otherwise row 1 is assumed. Load is capped at 1,000,000 rows per sheet.

Registration creates tables without you asking. Names are lowercased, every run of characters outside a-z0-9 becomes _, and leading and trailing underscores are trimmed.

SourceTable name
CSVlogical_name
XLSX, one sheet importedlogical_name
XLSX, several sheets importedlogical_name_sheetname per sheet

These carry origin: default. A name collision with an existing table is table.name_conflict, and registering the same logical_name twice is rejected the same way.

{
"file_id": "sales_q3",
"logical_name": "sales_q3",
"source": { "type": "upload" },
"format": "csv",
"size_bytes": 4823104,
"row_count": 84302,
"sheets": null,
"tables_created": ["sales_q3"],
"added_at": "2026-04-13T14:23:15Z",
"status": "ready"
}

source.type is upload or url; a URL source also carries source.url. status is always ready. Removing a file drops every table created from it and every table that lists it as a source.

A defined table is a view that unions one or more registered sources. Use it to query several uploads as one.

POST /worksheets/{worksheet_id}/tables
{
"name": "all_sales",
"sources": [
{ "file": "sales_q1" },
{ "file": "sales_q2" },
{ "file": "sales_q3" }
],
"union_strategy": "by_name",
"add_filename_column": true
}
FieldTypeRequiredNotes
namestringyesUsed verbatim — not sanitized like a default table name
sources[].filestringyesA registered logical_name
sources[].sheetstringnoFor XLSX; selects the file_sheet table
union_strategystringnoDefault by_name
add_filename_columnboolnoAdds _filename carrying the source file’s logical name
StrategyAlignmentFailure mode
by_nameBy column name; columns missing from a source come back NULLRarely fails
by_positionBy ordinal positionDiffering column counts fail at view creation
strictNames, order and types must match exactlyChecked before the view is built; reports the mismatching position

An unrecognised union_strategy string falls back to by_name without an error. A single-source definition is legal and just wraps the source. Union failures return table.union_incompatible; an unknown source is table.not_found; an empty sources array is table.union_incompatible.

{
"name": "all_sales",
"origin": "explicit",
"source_files": ["sales_q1", "sales_q2", "sales_q3"],
"row_count": 252906,
"column_count": 14,
"defined_at": "2026-04-13T14:30:00Z"
}

Dropping a defined table removes the view only; the underlying files and their default tables stay.

Four GET routes profile a table without a query: enriched schema, sample, per-column analytics, and distinct values with counts. Parameters, the four analytics result shapes, and the rule that decides when a distinct count is exact rather than estimated are in Worksheet profiling and analytics.

POST /worksheets/{worksheet_id}/query
{
"sql": "SELECT status, COUNT(*) AS cnt FROM sales_q3 WHERE amount > 1000 GROUP BY status ORDER BY cnt DESC",
"max_rows": 1000,
"timeout_ms": 30000
}
FieldTypeDefaultNotes
sqlstringOne statement, in DuckDB SQL
parametersobjectAccepted and never used
max_rowsint100000Clamped to 100,000; 0 means the default
timeout_msint30000Clamped to 300000

This is not the query DSL. Worksheet SQL goes straight to the worksheet’s own DuckDB instance and shares no syntax, operators or access checks with entity queries.

{
"rows": [{ "status": "completed", "cnt": "8412" }],
"schema": [
{ "name": "status", "type": "VARCHAR" },
{ "name": "cnt", "type": "BIGINT" }
],
"row_count_returned": 1,
"truncated": false,
"execution_ms": 42,
"query_id": "01HQZ..."
}

truncated is true when the result was cut at max_rows. query_id is generated per execution and is not addressable afterwards — there is no result cache and no fetch-by-id route.

Query failures are classified by matching the driver’s message, so the mapping is a heuristic: a message containing timeout becomes query.timeout, one containing memory becomes worksheet.memory_exceeded, and parser, syntax, catalog error or does not exist become query.syntax_error. Everything else is query.execution_error.

Result values are JSON-encoded by database type. Wide integers and decimals come back as strings to keep their precision — plan for that in the client.

Database typeJSON
BOOLEANboolean
TINYINT, SMALLINT, INTEGERnumber
BIGINT, HUGEINTstring
REAL, DOUBLEnumber
DECIMAL(p,s)string
VARCHARstring
DATE"YYYY-MM-DD"
TIME"HH:MM:SS"
TIMESTAMPRFC 3339, UTC
BLOBbase64 string
NULLnull

The TTL is absolute from creation. After it elapses:

WindowBehaviour
Before expires_atNormal
expires_at to +5 minutesEvery operation returns 410 worksheet.expired. DELETE still succeeds
After +5 minutesThe worksheet is evicted and closed; requests return 404 worksheet.not_found

A background pass runs every 30 seconds and closes anything past the grace window; a request arriving first evicts it inline. DELETE frees the memory immediately, which is worth doing on large uploads rather than waiting out a 24-hour TTL.

Every worksheet bound — TTL range, 500 MB uploads, 50 files, 100 tables, 2 GB of memory, 100,000 query rows — is a compile-time constant with no configuration key and no per-tenant override, and four request fields are clamped to their maximum rather than rejected. The full table, with the error each breach returns, is in Worksheet limits and quotas.

Per-tenant quotas are counted in the process’s memory in fixed one-minute windows: 20 concurrent worksheets, 10 creations, 30 file registrations, and 60 queries per minute per worksheet. A breach returns 429 with Retry-After: 60 and code rate_limit_exceeded. Only creation, file registration and query are counted; every other route — introspection, table definition, listing, delete — is uncounted.

Every failure uses one envelope:

{
"error": {
"code": "worksheet.expired",
"message": "worksheet expired at 2026-04-13T15:22:00Z",
"details": { "worksheet_id": "01HQZ...", "expired_at": "2026-04-13T15:22:00Z" },
"request_id": "01HQZ..."
}
}

This is not the entity API’s {"error","code","details"} envelope — the fields are nested under error and carry a request_id.

CodeHTTPMeaning
worksheet.not_found404No such worksheet on this replica, or evicted past the grace window
worksheet.expired410TTL elapsed, still inside the 5-minute grace window
worksheet.cpet_violation403The worksheet belongs to another tenant
worksheet.invalid_ttl400ttl_seconds outside 60–86400
worksheet.memory_exceeded507The 2 GB per-worksheet limit was hit during execution
file.not_found404Unknown file_id
file.uri_unreachable502Declared and mapped, never emitted — an unreadable URL fails as file.format_invalid
file.format_invalid400Unsupported format, unreadable bytes, or no usable sheet
file.size_exceeded413Upload over 500 MB, sheet over the row or column cap, or more than 50 files
file.sheet_selection_required400Several sheets hold data; name them in sheets
table.name_conflict409Table or file name already used, or more than 100 tables
table.not_found404Unknown table, or an unknown source in a definition
table.union_incompatible400Sources cannot be unioned under the chosen strategy
column.not_found404Unknown column in analytics or distinct
query.syntax_error400Parse or catalog error
query.timeout504Query exceeded its timeout
query.execution_error400Any other runtime failure
rate_limit_exceeded429A per-tenant quota was hit
invalid_request400Body is not valid JSON, or is empty

Two checks in the HTTP layer bypass the status map and always answer 400 regardless of the code they carry: a malformed multipart body reports file.size_exceeded, and GET on an unknown file_id reports file.not_found. The same codes are 413 and 404 everywhere else.

Worksheets need two DuckDB extensions — httpfs for URL registration and excel for XLSX — and both must be installed on the host beforehand. Automatic extension installation and autoloading are turned off on each worksheet’s database as it is created, so the process never reaches out to download anything at query time. At boot the service probes both extensions and logs the result; each worksheet then loads them at creation, and a load failure is a warning, not an error. The worksheet starts anyway, and the missing capability shows up later as an XLSX or URL registration failing. Check the startup log on an air-gapped build before concluding the API is broken.

Memory is the binding constraint: each worksheet holds up to 2 GB and 20 can be active per tenant, all inside the data.svc process. Size the pod for the concurrency you actually intend to allow, and keep the feature on a dedicated replica if worksheet load must not affect entity traffic.

ConcernPage
Schema, sample, analytics, distinctWorksheet profiling and analytics
Every bound, quota and clampWorksheet limits and quotas
Durable, modelled data instead of a scratch areaDatastores
Persistent file-to-table ingestionData Pipes
Querying entitiesQuery DSL
Block overviewData API
Endpoint-level HTTP referenceData API reference