Warp SQL protocol
Status: 1.0.1 (2026-10-20). Version 1 is stable: from here on it changes only by addition (§12).
Warp SQL is an open protocol for running SQL against a database server over plain HTTP, with results and bulk data carried in the Apache Arrow IPC stream format. It does for HTTP what Arrow Flight SQL does for gRPC: a small set of operations that any engine can implement and any HTTP client can call.
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be read as described in RFC 2119.
1. Goals and non-goals
Section titled “1. Goals and non-goals”Goals
- Any HTTP client can talk to it. A browser
fetch,curl, or any language’s standard HTTP library is enough. No gRPC, no protobuf, no code generation. - Arrow all the way. Query results, query parameters and bulk-ingest uploads
are Arrow IPC streams (
application/vnd.apache.arrow.stream). Control messages are small JSON documents. - Long queries survive real networks. A query can be submitted, left running, polled and fetched later, and a dropped result download can resume where it stopped. No request needs to stay open longer than a proxy allows.
- Short queries stay short. One request can run a query and stream its result.
- Safe by construction. Every handle a server gives out (session, statement, prepared statement) is checked against the caller’s identity on every use.
- Engine-neutral. Nothing in the protocol depends on one database engine.
Non-goals
- Administration. Anything that can be done in SQL (users, grants, killing a session, settings) is done in SQL through this protocol, not with extra endpoints.
- Defining SQL. The SQL dialect is the engine’s.
- Replacing ADBC, JDBC or ODBC. Those are client APIs; Warp SQL is a wire protocol their drivers can speak.
2. Conventions
Section titled “2. Conventions”2.1 Transport
Section titled “2.1 Transport”- Servers MUST support HTTP/1.1 and MAY support HTTP/2 and HTTP/3. Every operation MUST work over HTTP/1.1 through an ordinary reverse proxy. Nothing in the protocol relies on HTTP/2 streams, trailers or server push.
- Servers MUST offer TLS, and SHOULD refuse plain HTTP except on a loopback address.
Servers MAY require client certificates (mutual TLS), either in addition to the
authentication in §4 or as the credential itself (the
client_certificategrant, §4.4). A server MUST verify a client certificate against the certificate authorities it trusts for clients, and MUST NOT take an identity from a certificate it has not verified. A server that checks revocation and finds a client certificate revoked refuses the handshake (TLS alertcertificate_revoked). - All endpoints live under a base path. This document writes it as
/warp; a server MAY mount the protocol under another prefix and MUST say so in its documentation. Version 1 operations are under/warp/v1.
2.2 Media types
Section titled “2.2 Media types”| Content | Media type |
|---|---|
| Control messages | application/json (UTF-8) |
| Errors | application/problem+json (RFC 9457) |
| Results, parameters, uploads | application/vnd.apache.arrow.stream |
An Arrow body is always one complete IPC stream: a schema message, zero or more record batches (with any dictionary batches they need), and the end-of-stream marker. A stream that ends without the end-of-stream marker is incomplete (§6.6).
Arrow schemas embedded in JSON (for example a prepared statement’s parameter schema) are an IPC stream containing only the schema message and the end-of-stream marker, base64-encoded (standard alphabet, with padding).
2.3 Identifiers
Section titled “2.3 Identifiers”Session, statement and prepared-statement ids are opaque strings chosen by the
server, at most 128 characters from [A-Za-z0-9._~-]. Servers MUST generate them
with at least 128 bits of randomness. Ids are not credentials: servers MUST still
check the caller on every use (§9.2).
2.4 JSON
Section titled “2.4 JSON”Field names are snake_case. Both sides MUST ignore JSON fields they do not
recognise, so later minor versions can add fields: a server MUST NOT refuse a request
because its JSON body has a field the server doesn’t know. Timestamps are RFC 3339 strings
in UTC (2026-10-16T21:04:05.123Z). Durations are integers with the unit in the
name (timeout_ms, idle_timeout_s).
Fields whose value is one of a listed set of strings (a statement’s state, a
cancel_reason, a reason, …) can gain values later; §12 says how clients treat a value
they don’t know.
2.5 Request headers defined by this protocol
Section titled “2.5 Request headers defined by this protocol”| Header | Used on | Meaning |
|---|---|---|
Authorization |
all except §3 | Bearer <access token> (§4) |
Idempotency-Key |
submit, execute, ingest | Makes a retried request safe (§6.7) |
Prefer |
submit, execute, status | wait=<seconds> and respond-async (RFC 7240) |
Warp-Statement-Timeout |
submit, execute | Milliseconds before the server cancels the statement |
Warp-Retain-Result |
submit, execute | true or false: keep the result for re-reading (§6.5) |
Warp-Query-Tag |
submit, execute, ingest | A JSON object the server may record with the statement, for observability (below) |
Warp-IPC-Compression |
any request that returns Arrow | The Arrow buffer compression the client accepts, in its order of preference: lz4_frame, zstd, none (§8.2) |
Warp-Result-Partitions |
submit, execute | The most partitions the client reads at once (§6.10) |
Warp-Result-Order |
submit, execute | preserve or any: whether the client needs rows in order (§6.10) |
Warp-Result-Locations |
submit, execute | remote: the client can read partitions held by other servers (§6.2, capability result_locations) |
Warp-Query-Tag carries a JSON object, serialized in ASCII (any other character
written as a \u escape) and at most 4,096 bytes long, such as
{"job":"nightly-load","run":"2026-10-19"}. It labels the statement (or ingest) the
request creates, so it can be found in the server’s logs, query history or audit trail; a
partitioned ingest (§8.3) takes it on the request that opens it. A
server MAY record it and MAY ignore it; a server that reads it SHOULD refuse one that is
not a JSON object, not ASCII or too long with 400 and code bad_request. A tag never
changes what the statement does.
Servers MUST accept request headers totalling at least 16 KiB (identity tokens can
be large) and MUST answer a larger header block with 431 Request Header Fields Too Large (code headers_too_large). The limit SHOULD be configurable.
2.6 Response headers defined by this protocol
Section titled “2.6 Response headers defined by this protocol”| Header | On | Meaning |
|---|---|---|
Warp-Request-Id |
every response | The request_id of this request (§11), also in the server’s logs |
Warp-Statement-Id |
statement responses | The statement the response belongs to, on the error response for a statement that failed or was cancelled (§6.1) too |
Warp-IPC-Compression |
Arrow responses | The Arrow buffer compression used: lz4_frame, zstd or none (§8.2) |
Warp-Result-Partitions, Warp-Result-Order |
streamed results | How a split result is spread over partitions (§6.10); both MUST be present when the result has more than one partition |
Retry-After |
429, 503 |
When retrying can help: the seconds to wait, as an integer (the delta-seconds form of RFC 9110; servers MUST NOT send the date form) |
2.7 Names for extensions
Section titled “2.7 Names for extensions”The capabilities (§3.1), session options (§5.3), server-info keys (§10), error codes and
reason values (§11) that this document defines belong to the protocol, and so does every
such name a later version adds. A server’s own names in those places begin with a prefix
that names its implementation or engine, in lower case, followed by _
(duckdb_arrow_lossless_conversion, acme_instance_id). Later versions of this document
never define a name that begins with a prefix an implementation uses this way, so an
extension can’t collide with a name the protocol adds. A client ignores an extension name
it doesn’t know, as it ignores any unknown name (§12). Headers whose names begin with
Warp- are the protocol’s; a server’s own headers take other names.
3. Discovery and health
Section titled “3. Discovery and health”These endpoints need no authentication.
3.1 GET /warp
Section titled “3.1 GET /warp”{ "protocol": "warp-sql", "protocol_versions": ["1"], "server": {"name": "Example SQL Server", "version": "3.2.1"}, "capabilities": ["prepared_statements", "transactions", "ingest", "long_poll", "ipc_compression_zstd", "ipc_compression_lz4_frame", "result_partitions", "partitioned_ingest", "mtls_identity", "oauth_browser", "openapi"], "auth": {"grant_types": ["password", "token_exchange", "client_certificate"], "oauth_browser": {"provider": "Okta"}}, "links": {"openapi": "/warp/v1/openapi.json"}}capabilities lists the optional features the server supports. A client MUST NOT use an
optional feature the server does not list, and ignores names it doesn’t know.
auth.grant_types lists the grants of POST /warp/v1/auth/token (§4.1); auth.oauth_browser
describes browser sign-in (§4.3) when the server offers it, and links (optional) says where
the server’s optional documents are (§3.3). The capabilities of version 1:
| Capability | Section | What it gates |
|---|---|---|
prepared_statements |
§7 | POST …/prepared, its execute and DELETE |
transactions |
§5.5 | The autocommit option set to false, and POST …/commit and …/rollback |
ingest |
§8.1 | POST …/ingest (bulk ingest in one request) |
partitioned_ingest |
§8.3 | POST …/ingests and the rest of partitioned ingest |
long_poll |
§6.3 | Prefer: wait honoured on a statement’s status |
ipc_compression_lz4_frame, ipc_compression_zstd |
§8.2 | Arrow buffer compression with that codec, in both directions |
result_partitions |
§6.10 | Results split over several partitions on this server, read concurrently (Warp-Result-Partitions, Warp-Result-Order) |
result_locations |
§6.2 | Partitions held by other servers (Warp-Result-Locations: remote, non-null location) |
mtls_identity |
§4.4 | The client_certificate grant: signing in with a TLS client certificate |
oauth_browser |
§4.3 | Browser sign-in for clients that cannot take a redirect themselves |
openapi |
§3.3 | GET /warp/v1/openapi.json, the OpenAPI description, and links.openapi in discovery |
Everything else in this document is core: every server implements it. A feature added after 1.0 that a client must ask for, or that changes what a request means, comes with a capability of its own (§12).
3.2 GET /warp/v1/health
Section titled “3.2 GET /warp/v1/health”200 with {"status": "serving"} when the server accepts new work, otherwise 503
with {"status": "not_serving", "reason": "draining" | "starting" | "check_failed"}.
A server MAY run a configurable health query to decide; if that query hangs, the
endpoint MUST still answer (503, check_failed) within a bounded time.
3.3 GET /warp/v1/openapi.json (optional capability openapi)
Section titled “3.3 GET /warp/v1/openapi.json (optional capability openapi)”The OpenAPI 3.1 description of the protocol version
the server implements: the document published with this specification (openapi.yaml, next
to it), as JSON, with Content-Type: application/json. It describes every endpoint of
Appendix B with its parameters, headers, request and response schemas and errors, for tools
(API browsers, client generators, validators). This specification is the definition: where
the two differ, this text is right and the document has a bug.
Like §3.1, it needs no authentication. The document is the same for every caller and changes
only when the server does, so the answer is cacheable: it SHOULD carry Cache-Control with a
max-age and an ETag, and a request whose If-None-Match names that ETag SHOULD get 304 Not Modified. A server MAY set the document’s servers to its own address and MAY add
specification extensions (x- fields); otherwise it serves the document unchanged. Endpoints
of a server’s own, outside the protocol, are described elsewhere.
A server MAY offer it. One that does lists openapi in capabilities and gives the path in
discovery’s links (a path on the server’s origin, base path included, or an absolute URL):
"links": {"openapi": "/warp/v1/openapi.json"}A server without the capability answers 404 with code not_found.
4. Authentication
Section titled “4. Authentication”4.1 POST /warp/v1/auth/token
Section titled “4.1 POST /warp/v1/auth/token”Exchanges a credential for an access token. The credential travels in a JSON body, never in a URL.
POST /warp/v1/auth/tokenContent-Type: application/json
{"grant_type": "password", "username": "joe", "password": "s3:cret"}{"access_token": "eyJhbGciOi…", "token_type": "Bearer", "expires_in": 3600}Grant types:
password:usernameandpassword. Because they are separate JSON fields, a password may contain any character,:included.token_exchange:subject_token, a token from an identity provider the server trusts (for example an OpenID Connect ID token). The server validates it and issues its own access token.client_certificate(optional capabilitymtls_identity): no fields. The verified client certificate of the TLS connection the request arrives on is the credential; see §4.4.
The access token’s format is the server’s choice; clients MUST treat it as opaque. A token MAY be valid only on the server instance that issued it or across a cluster; that is server policy.
Failures return 401 with code invalid_credentials. The error MUST NOT say which
part of the credential was wrong.
4.2 Using the token
Section titled “4.2 Using the token”Every other request carries Authorization: Bearer <access token>. A missing,
malformed, expired or forged token gets 401 with code invalid_token and a
WWW-Authenticate: Bearer header. Servers MUST NOT read credentials from cookies,
which keeps the protocol immune to cross-site request forgery by construction.
Why a token stopped working. A token can stop standing before it expires: a server
that ends a principal’s tokens when its credentials change, when it is dropped or
disabled, or when a token is revoked refuses them from then on. When the server refuses
an authentic token, one it issued itself and has verified as unaltered (its signature
checks, or it is one of the server’s own opaque tokens), it SHOULD say why in a reason
field of the problem document (§11):
reason |
When |
|---|---|
token_expired |
The token’s lifetime ended |
token_revoked |
The token itself was revoked (for example a scoped token, or one it was derived from) |
credentials_changed |
The principal’s password or other credentials changed after the token was issued |
principal_dropped |
The principal was dropped or disabled, so it can no longer sign in |
A server MUST NOT send a reason for a token it cannot verify as its own (malformed,
forged, altered, signed with another key, unknown): the reason tells something about the
principal the token names, and only the holder of a real token may learn it. The same
token never starts working again, so a client SHOULD NOT retry with it. A client that holds
the credentials it signed in with MAY sign in again (once) after invalid_token without a
reason, with token_expired, or with a reason it doesn’t know. After token_revoked
(someone withdrew that access on purpose), credentials_changed or principal_dropped
(signing in with the old credentials fails too) it SHOULD NOT sign in again on its own, and
SHOULD show the reason to the person instead. Sessions the token was used with may have
ended for the same reason (§5.6).
4.3 Browser sign-in (optional capability oauth_browser)
Section titled “4.3 Browser sign-in (optional capability oauth_browser)”A person signs in with the server’s identity provider (OpenID Connect or any OAuth 2.0 authorization server) in a web browser, and the client receives an access token of the server’s own, as from §4.1. The server is the identity provider’s client: its client id, client secret, scopes and endpoints are server configuration that clients never see. The client needs no redirect handling of its own, so command-line tools, desktop drivers and web pages all use the same flow:
- The client creates a flow and receives its id, a poll secret and the URL where the person signs in.
- The person opens that URL in a browser and signs in with the identity provider, which sends the browser back to the server’s callback. The server completes the sign-in with the identity provider there.
- The client polls the flow with the poll secret, and receives the access token once.
Discovery. A server that offers browser sign-in lists oauth_browser in
capabilities and describes it in auth.oauth_browser (§3.1):
"auth": {"grant_types": ["password", "token_exchange"], "oauth_browser": {"provider": "Okta"}}provider (optional) is the identity provider’s name for people to read (“Sign in with
Okta”); a client without one says “single sign-on”. A server without the capability
answers the endpoints below with 501 and code not_implemented.
4.3.1 POST /warp/v1/auth/oauth/flows
Section titled “4.3.1 POST /warp/v1/auth/oauth/flows”Needs no authentication. The body is optional: no body, {}, or
{"return_to": "/ui/"}return_to is for clients in a browser (§4.3.3). The answer is 201, with Location
naming the flow:
{ "flow_id": "f_q3Xv…", "poll_secret": "Wb1n…", "authorize_url": "https://idp.example.com/authorize?response_type=code&client_id=…", "poll_interval_s": 2, "expires_at": "2026-10-19T21:14:05Z"}flow_idis an identifier (§2.3).poll_secretis at least 128 bits of randomness, in the same alphabet. The poll secret appears in this answer only: it MUST NOT be part ofauthorize_url, the callback, any redirect, the flow’sLocation, or the server’s logs. Whoever sees the authorize URL or the flow id (browser history, a shared screen, a proxy’s log) therefore cannot collect the token.authorize_urlis the identity provider’s authorization endpoint with the server’s request (§4.3.4). The client opens it in a browser, or shows it for the person to open.- The flow is forgotten at
expires_at, finished or not. Servers SHOULD give flows a lifetime of 5 to 15 minutes. - The answer carries
Cache-Control: no-store.
Anyone can create a flow, so a server MUST bound the flows it holds: at most a
configured number pending at once in all, and SHOULD also bound them per client address.
Beyond either limit it answers 429 with code rate_limited and Retry-After. A
return_to that §4.3.3 does not allow gets 400 with code invalid_return_to.
4.3.2 Polling: POST /warp/v1/auth/oauth/flows/{flow_id}
Section titled “4.3.2 Polling: POST /warp/v1/auth/oauth/flows/{flow_id}”The poll secret travels in the JSON body, never in the URL or a header:
POST /warp/v1/auth/oauth/flows/f_q3Xv…Content-Type: application/json
{"poll_secret": "Wb1n…"}The answer is 200 with Cache-Control: no-store and one of:
state |
Body | Then |
|---|---|---|
pending |
{"state": "pending"} |
Poll again after poll_interval_s |
complete |
{"state": "complete", "access_token", "token_type", "expires_in"}, as §4.1 |
The flow is gone |
failed |
{"state": "failed", "error": {"code", "detail"}} |
The flow is gone |
A completed flow’s token is returned exactly once, and a failed flow’s error once: from
then on, and after expires_at, the flow is 404 with code flow_not_found. A wrong
poll secret gets the same 404 flow_not_found as a flow that does not exist, so ids
cannot be probed; a body without a poll_secret string is 400 bad_request. GET and
other methods are 405. The server MUST compare poll secrets in constant time (or
compare digests of them). Clients SHOULD poll no more often than poll_interval_s.
The error.code of a failed flow:
code |
When |
|---|---|
access_denied |
The person or the identity provider refused the sign-in (the provider’s error) |
invalid_credentials |
The identity provider’s answer did not verify, or names no principal allowed to sign in (as §4.1: the detail MAY say who signed in, never why a credential was wrong) |
identity_provider_error |
The server could not complete the sign-in with the identity provider (unreachable, refused the code) |
A server that requires identity match (§4.4) applies it when the token is collected: a
poll on a connection whose client certificate maps to another identity gets 401 with
code certificate_identity_mismatch, and the flow stays complete for the right client.
The token is not bound to a certificate (§4.4); a server MAY also refuse to complete a
browser sign-in for a principal that may sign in only with its certificate.
4.3.3 Clients in a browser: return_to
Section titled “4.3.3 Clients in a browser: return_to”A web page (a server’s own web UI, say) creates the flow with return_to, keeps
flow_id and poll_secret in memory or sessionStorage, and sends the browser to
authorize_url. After the callback, the server sends the browser to return_to with the
flow id in the URL’s fragment:
HTTP/1.1 303 See OtherLocation: /ui/#warp_oauth_flow=f_q3Xv…The page reads the fragment, removes it from the address bar, polls the flow once with the
poll secret it kept and so receives the token, without the token ever being in a URL. The
server redirects to return_to whether the sign-in succeeded or failed; the poll says
which. A page that finds no poll secret for the flow named in its fragment did not start it
and MUST ignore it.
return_to MUST be a path on the server’s own origin. A server MUST refuse
(invalid_return_to), and never redirect to, a return_to that is not a string of at most
2048 characters that:
- starts with
/and not with//; - contains no
\, no#, no whitespace or control character and no character outside printable ASCII (%-encode the rest), so no browser can read it as another host; - is not an absolute URL (no
scheme:before the first/).
A server MAY restrict it further (to its web UI’s paths, say). It MAY send the redirect as
an absolute URL on its configured public origin (for example when the identity provider’s
redirect reaches it through a proxy on another origin). Without return_to, the callback
answers with a page saying that the sign-in is done and the tab can be closed, or why it
failed.
4.3.4 The callback and the identity provider
Section titled “4.3.4 The callback and the identity provider”The callback is where the identity provider sends the browser: an endpoint of the server
under its base path, GET /warp/v1/auth/oauth/callback unless the server documents
another. Clients never call it; it appears to them only as the redirect_uri inside
authorize_url. Operators register the callback’s public URL with the identity provider
as the client’s redirect URI.
The server’s request to the identity provider MUST use the authorization code flow (RFC 6749 §4.1) with:
state: a value of at least 128 bits of randomness per flow, which is neither the flow id nor the poll secret; the callback finds its flow bystateonly;- PKCE (RFC 7636) with
code_challenge_method=S256: the server keeps the verifier and sends it with the code; - for OpenID Connect, a
nonceper flow, which the server checks in the ID token.
The callback for a state it does not know, or for a flow that is no longer pending (a
second callback, an expired flow), changes nothing. A callback that the identity provider
answered with error fails the flow (access_denied). Otherwise the server exchanges the
code at the identity provider’s token endpoint, verifies what it receives (for an ID token:
issuer, audience, expiry, nonce, and its signature unless it came straight from the token
endpoint over verified TLS), maps it to a principal under the same rules as its
token_exchange grant, and completes the flow, or fails it.
Every callback answer carries Cache-Control: no-store, Referrer-Policy: no-referrer
and X-Frame-Options: DENY. A page it serves carries a Content-Security-Policy that allows
no script (default-src 'none', plus at most inline styles), escapes everything it shows
(the provider’s error text is attacker-chosen), and has no links or resources that would
carry the callback’s URL, which holds the code, elsewhere.
Phishing. Anyone can create a flow and send its authorize_url to someone else; if
that person signs in, the flow’s creator collects their token. This is inherent to sign-in
by polling (as for RFC 8628 §5.4). Clients SHOULD
open the URL themselves rather than ask the person to copy one, servers SHOULD keep flows
short-lived and SHOULD say on the completion page which server was signed in to and as whom,
and people should complete only sign-ins they started.
4.4 Client certificate sign-in (optional capability mtls_identity)
Section titled “4.4 Client certificate sign-in (optional capability mtls_identity)”Certificate sign-in is opt-in: a server that verifies client certificates offers it only
when configured to, and otherwise uses them to admit connections only. A server with
the capability lists mtls_identity in capabilities and client_certificate in
auth.grant_types (§3.1); one without it answers the grant with 400 and code
unsupported_grant_type. The client connects with TLS,
presents its certificate, and asks for a token with no password:
POST /warp/v1/auth/tokenContent-Type: application/json
{"grant_type": "client_certificate"}The server maps the certificate it verified during the handshake to a principal and answers as for any grant (§4.1), or refuses:
| Code | Status | When |
|---|---|---|
certificate_required |
401 | The connection presented no client certificate (a server where certificates are optional) |
unknown_certificate_identity |
401 | The certificate has no identity of the kind the server maps, has more than one (ambiguous), or names no principal allowed to sign in this way |
certificate_revoked |
401 | The certificate was revoked, or no longer verifies, after the connection was set up (see below) |
Mapping. The server takes the identity from one configured part of the verified
leaf certificate: an rfc822Name subject alternative name (an e-mail address; the
recommended default), a uniformResourceIdentifier subject alternative name (for
example a SPIFFE ID, spiffe://example.org/ns/etl/sa/loader), or the subject’s common
name. A certificate with no value of that kind, or with more than one, MUST be refused
(unknown_certificate_identity); a server MUST NOT pick one of several. An identity is
printable ASCII (0x20-0x7E) whatever its source: a value with any other character
(control characters, NUL, Unicode format and invisible characters such as bidirectional
overrides and zero-width spaces, non-ASCII letters) MUST be refused, and a server MUST
escape it wherever it shows it (error details, logs), so it can’t spoof what a reader
sees. The certificate proves the identity
only: what the principal may do is decided by the server’s authorization (§9.1), exactly
as for any other grant. A server MAY refuse identities it does not know, and SHOULD
document how it compares them. The recommended rule: a URI-form identity (scheme://…,
such as a SPIFFE ID, whose path is case-sensitive) matches only exactly, case included; an
e-mail address or common name matches ignoring ASCII case.
Certificate-bound tokens. A token issued for a client_certificate sign-in MUST be
bound to the certificate, as in RFC 8705 §3:
the server records the SHA-256 thumbprint of the certificate’s DER encoding (in a JWT,
the cnf claim’s x5t#S256 member, base64url without padding; in an opaque token, its
equivalent). Every request that uses a bound token MUST arrive on a TLS connection whose
verified client certificate has the same thumbprint; otherwise the server answers 401
with code certificate_mismatch and a WWW-Authenticate: Bearer header, whatever the
endpoint. This covers every connection a client opens: the partitions of a split result
(§6.10) are read, and the parts of a partitioned ingest (§8.3) uploaded, on
connections of their own, and each MUST present the same certificate. Keep-alive connections are checked per request, not once per connection. A session opened with a bound
token keeps the binding: every later request on it (its statements, results, partitions,
ingest parts, cancel, close) MUST carry a token bound to the same certificate, even one of
the same principal from another grant; otherwise 401 with code certificate_mismatch.
Likewise a session opened with an unbound token is used only with unbound tokens: what runs in
a session always runs under the binding of the request that runs it. Tokens a principal derives from a bound
token (for example a server’s scoped tokens) MUST carry the same binding.
Revocation. A server that checks revocation (for example against certificate
revocation lists) checks every certificate of the client’s chain, not only the leaf (a
revoked intermediate CA revokes what it issued), and refuses a revoked certificate at the
handshake. It SHOULD fail closed: a CA with no revocation data, or data past its
nextUpdate, fails the chain unless the operator explicitly allows it. A refusal at the
handshake is a TLS alert (certificate_revoked, or certificate_required when the client
sent no certificate); with TLS 1.3 the client’s side of the handshake is already over
when the server checks the certificate, so the alert reaches the client on its first
read. Clients SHOULD report such an alert as the refusal it stands for (this section’s
certificate_revoked and certificate_required), not as a server they could
not reach. It SHOULD pick
up new revocation data without a restart and SHOULD then re-check the certificates of
connections already open, answering their next request with certificate_revoked; it
SHOULD also re-check them when the data it verified them with passes its nextUpdate. With
several CRLs from one CA it uses the newest (highest CRL Number, else latest
thisUpdate), and a revocation list it cannot apply completely (a delta CRL it does not
support, one not signed by the CA it names) fails closed.
Refusals are security events. A server SHOULD log each certificate_mismatch,
certificate_identity_mismatch, certificate_revoked and unknown_certificate_identity
refusal with the principal, the token’s id, the certificate presented and the client’s
address, and SHOULD report a token’s successful use only after all of these checks. Refusals cost an
attacker nothing, so a server SHOULD rate-limit their records per source (still counting
what it holds back) and close a connection after a few failed authentications, and SHOULD
also record client certificates its handshake refuses.
Proxies. The certificate is only seen by whoever ends TLS. Deployments MUST end TLS at the Warp SQL server (a TCP or TLS-passthrough load balancer) for this grant, or have the proxy verify the client certificate and pass it on in a way the server is configured to trust. A server MUST NOT trust a forwarded-certificate header unless it is explicitly configured to, and then only from the proxies it names.
Identity match. A server MAY require that a password or token_exchange sign-in
on a connection with a client certificate be for the identity the certificate maps to,
and refuses one that is not with 401 and code certificate_identity_mismatch. A server
that compares a password sign-in’s username SHOULD do so before checking the password,
so the refusal says nothing about the password.
5. Sessions
Section titled “5. Sessions”A session is the server-side state of one client connection: current catalog and schema, settings, temporary objects, an open transaction. Every statement runs in a session.
5.1 POST /warp/v1/sessions
Section titled “5.1 POST /warp/v1/sessions”{"options": {"catalog": "lake", "schema": "analytics", "autocommit": true}}201 with:
{ "session_id": "s_8f3k…", "options": {"catalog": "lake", "schema": "analytics", "autocommit": true}, "transaction": "none", "idle_timeout_s": 900}options (optional in the request) sets session options (§5.3) as the session opens. An
option the server doesn’t have, or a bad value, fails the whole request with the error
§5.3 gives that option (400, unknown_option or invalid_option_value, with the
option’s name in detail), and no session is created. The response’s options holds
every option the session has, with its current value, not only the ones the request set.
transaction is the session’s transaction (§5.5), read-only:
| Value | Meaning |
|---|---|
none |
No transaction is open: each statement commits as it runs |
open |
A transaction is open, from SQL BEGIN (or START TRANSACTION) to its COMMIT or ROLLBACK, or for as long as autocommit is false |
aborted |
A statement failed or was cancelled inside the transaction, and the engine refuses every statement until a rollback |
aborted says what the engine does, not merely that a statement failed: an engine that
keeps a transaction usable after some errors (some do after a syntax error) reports
open then. With autocommit false, a commit or rollback, through §5.5’s calls or in
SQL, ends the transaction and starts the next, so the value stays open (and is open
again once an aborted one is rolled back).
The session belongs to the principal the access token identifies (§9.1), for its whole life.
5.2 GET /warp/v1/sessions/{session_id}
Section titled “5.2 GET /warp/v1/sessions/{session_id}”Returns the session document above, with the session’s current options and
transaction: a change made through SQL (USE other_schema, BEGIN) shows here too, once
the statement that made it has ended (its result read, or its failure reported). Clients
SHOULD read transaction here rather than track BEGIN and COMMIT themselves. Clients MAY use it as a cheap
liveness probe: for a session the server ended (§5.6) it answers 401 session_ended
with the reason, as every other request naming the session does, and it does not wait
for a statement the session is running.
5.3 PATCH /warp/v1/sessions/{session_id}
Section titled “5.3 PATCH /warp/v1/sessions/{session_id}”Sets options. Body {"options": {…}}. The server applies each option on its own, so one
bad option doesn’t stop the others, and answers 200 with every option the session has
and the failures, by option name (errors is {} when every option was set):
{"options": {"catalog": "lake", "schema": "analytics", "autocommit": true}, "errors": {"schema": {"code": "invalid_option_value", "detail": "schema 'x' does not exist"}}}options is the session’s options after the request, as GET of the session shows them
(§5.2); an option that failed keeps its earlier value. Each entry of errors has the
code and detail a problem document would (§11).
Standard options:
| Option | Type | Meaning |
|---|---|---|
catalog |
string | Current catalog |
schema |
string | Current schema |
autocommit |
boolean | false starts a transaction that §5.5 ends |
session_tag |
object | JSON recorded with the session for observability |
A server MAY support more options, named as §2.7 says (duckdb_arrow_lossless_conversion);
names it does not support get unknown_option.
5.4 DELETE /warp/v1/sessions/{session_id}
Section titled “5.4 DELETE /warp/v1/sessions/{session_id}”Ends the session, cancels its running statements, and frees its retained results.
204.
5.5 Transactions
Section titled “5.5 Transactions”With autocommit false, POST /warp/v1/sessions/{session_id}/commit and
POST /warp/v1/sessions/{session_id}/rollback end the current transaction (204)
and the next statement starts a new one. SQL transaction statements (BEGIN,
COMMIT, ROLLBACK) also work, as the engine allows; with autocommit false a SQL
COMMIT or ROLLBACK starts the next transaction too, as the calls do. The session
document’s transaction (§5.2) says where the session stands. Capability: transactions.
5.6 Sessions that no longer exist
Section titled “5.6 Sessions that no longer exist”A request naming a session the server does not have gets 404 with code
session_not_found, whether the session was closed, evicted for idleness, never
existed, or belongs to someone else. A server that knows the session lives on
another instance of the same cluster SHOULD answer 421 Misdirected Request with
code session_elsewhere, so a router can retry the right instance.
A server MUST NOT create a session implicitly for an unknown id. The client’s state (settings, temporary tables, an open transaction) is gone, and only the client can decide whether to start over. Drivers SHOULD create a new session, replay the options they set, and report the loss of temporary state.
A session the server ended for a reason the client must know about gets 401 with
code session_ended and a reason field in the problem document (§11), one of:
reason |
When |
|---|---|
killed |
An administrator ended it (e.g. SQL KILL SESSION) |
credentials_changed |
The principal’s password or credentials changed, or something else the server ties the session’s rights to |
principal_dropped |
The principal was dropped or disabled |
token_revoked |
The token the session was opened with was revoked |
token_expired |
The token the session was opened with expired, and the session could do nothing more without it (for example a scoped token’s session) |
The token is checked before the session (§4.2), and the events that end a session for
credentials_changed, principal_dropped, token_revoked or token_expired usually
end the token it was opened with too. So a request with the session’s own token gets
invalid_token with the same reason first, and session_ended reaches a client that
has signed in again (or holds another token of the same principal) and uses the old
session; a killed session’s token still stands, so its next request gets
session_ended directly. Either way the client learns the reason from the first refusal.
The session is gone either way, and only the client can decide to start a new one. After
session_ended with credentials_changed, principal_dropped, token_revoked or
token_expired, the client MUST get a new access token before it creates a new session
(the old one no longer stands, or no longer carries what the session needed). After
killed, or a reason it doesn’t know, it MAY create a new session with the token it
has.
5.7 Idle sessions
Section titled “5.7 Idle sessions”A session with no request and no running statement for idle_timeout_s MAY be
evicted. A running statement, or a retained result not yet expired, keeps a session
alive. A request still in progress counts as a request until its response ends, so a read
of one of the session’s results that is under way, however slowly the client reads, keeps
it alive too (an unretained result’s last batches go out after its statement has finished).
6. Statements
Section titled “6. Statements”6.1 Submit: POST /warp/v1/sessions/{session_id}/statements
Section titled “6.1 Submit: POST /warp/v1/sessions/{session_id}/statements”POST /warp/v1/sessions/s_8f3k…/statementsAuthorization: Bearer eyJ…Content-Type: application/jsonAccept: application/jsonPrefer: wait=5Idempotency-Key: 7c1e0a52-…
{"sql": "SELECT region, sum(amount) FROM sales WHERE year = ? GROUP BY region", "parameters": [2026]}parameters (optional) holds bind parameters, which are values, never SQL text:
servers MUST bind them through the engine’s parameter mechanism and MUST NOT splice them
into the statement. It is either an array, binding positionally (?, or $1, $2, … in
engines that number them), or an object, binding by name ($name in engines that support
named parameters):
{"sql": "SELECT * FROM sales WHERE region = ? AND year = ?", "parameters": ["EU", 2026]}{"sql": "SELECT * FROM sales WHERE region = $region", "parameters": {"region": "EU"}}Values are JSON scalars: numbers, strings, booleans and null, each typed as the engine
infers for that parameter. A null takes the type the engine infers for its parameter;
where the statement doesn’t fix one (? + 1), the client casts in SQL
(CAST(? AS INTEGER)), as in other SQL databases. For exact types (decimals, timestamps,
binary, nested values) use a prepared statement with Arrow parameters (§7).
An integer keeps its exact value. JSON parsers commonly read an integer outside the 64-bit
range as a double, which rounds it (10^38 - 1 becomes 1e38): a server MUST bind such an
integer exactly (as a 128-bit integer where the engine has one) or refuse it with
bad_request, never as a rounded value. A client that must not depend on either sends it
as a string and casts in SQL.
How the server answers depends on Accept and Prefer:
- Streamed (one round trip). With
Accept: application/vnd.apache.arrow.streamand norespond-async, a query’s result streams in the response body:200,Content-Type: application/vnd.apache.arrow.stream, andWarp-Statement-Idnaming the statement. A statement that returns no rows (DML, DDL) answers200with a JSON statement document (§6.2) instead; clients MUST checkContent-Type. - Status document. Otherwise the server answers
201with the statement document (§6.2) andLocation: /warp/v1/sessions/{session_id}/statements/{statement_id}. WithPrefer: wait=Nthe server MAY hold the response up to N seconds for the statement to finish; withPrefer: respond-asyncit SHOULD answer as soon as the statement is accepted. Without either, the wait is the server’s choice.
An error found before execution starts (a syntax error, a permission denial) is an
error response (§11), not a statement document. So is a statement that has failed or
been cancelled by the time the server answers its submission, whether or not it had
produced rows: the response is the error response for the statement’s error
(execution_error, statement_cancelled, statement_timeout, …), with
Warp-Statement-Id naming the statement, never a success status with a failed or
cancelled document. The problem document of a cancelled statement also carries its
cancel_reason (§6.2).
6.2 The statement document
Section titled “6.2 The statement document”{ "statement_id": "q_19dd…", "state": "running", "submitted_at": "2026-10-16T21:04:05.123Z", "started_at": "2026-10-16T21:04:05.130Z", "progress": {"fraction": 0.42, "rows_produced": 1048576}, "result": { "kind": "rows", "schema": "<base64 Arrow schema>", "partitions": [{"index": 0, "location": null, "batches": null, "rows": null}], "retained": true }}| Field | When | Meaning |
|---|---|---|
statement_id |
always | The statement’s id (§2.3) |
state |
always | queued, running, succeeded, failed or cancelled; the last three are terminal |
submitted_at |
always | When the server accepted the statement |
started_at |
once it has started | When it began to run (a statement cancelled while queued never has one) |
finished_at |
in a terminal state | When it reached that state |
progress |
optional, while running | Every field in it is optional: fraction (0 to 1), rows_produced |
result |
see below | What the statement produces, or null |
error |
failed, cancelled |
The statement’s error, a problem document (§11) |
cancel_reason |
cancelled |
Why it was cancelled (below) |
result is null until the server knows what the statement returns. A statement that
returns rows has a rows result as soon as its schema is known, while it runs too: its
partitions then list the partitions decided so far (§6.10), with batches and rows
null, and retained says whether the result is kept (§6.5). A failed or cancelled
statement’s result is null.
A cancelled statement’s cancel_reason is one of client, timeout, session_closed,
killed, cancelled, privilege_revoked or server_shutdown. killed: an
administrator ended the session (§5.6) and its work with it; cancelled: the engine
cancelled it for someone else, the session going on (an administrator’s, or the same
principal’s, SQL, such as an engine’s own cancel function). A cancelled statement’s
error code is statement_timeout when its timeout ran out and statement_cancelled
otherwise. A client MUST treat a failed or cancelled document as the statement’s
error whatever the response’s status code (an idempotent replay, §6.7, answers 200
with the statement’s document in any state).
A succeeded statement carries result:
"result": { "kind": "rows", "schema": "<base64 Arrow schema>", "partitions": [ {"index": 0, "location": null, "batches": 12, "rows": 98304} ], "retained": true, "expires_at": "2026-10-16T22:04:05Z"}or, for a statement that returns no rows:
"result": {"kind": "update", "rows_affected": 42}rows_affected is present when the engine counts the rows a statement changed (INSERT,
UPDATE, DELETE) and absent otherwise (DDL, SET, …); clients report it as unknown.
partitions lists the pieces of the result. Most results have one partition with
location: null, meaning “this server”. A client that asks for it (§6.10) may get a
result split into several partitions on this server, read concurrently over separate
connections; the result then also carries partition_order (§6.10). A client that did
not ask for partitions gets one partition on this server. batches and rows are known
only once the partition is complete and are null before that.
Partitions on other servers (capability result_locations). A partition’s location
MUST be null unless the request that created the statement carried
Warp-Result-Locations: remote, so a client that doesn’t follow locations never meets
one. To a client that sent it, a server with the capability MAY give a partition the base
URL of another server that holds it: an absolute URL of the same form as a server’s base
(https://worker-3.example:31337/warp). The client reads the partition at
{location}/v1/sessions/{session_id}/statements/{statement_id}/result?partition={index},
with the coordinator’s session and statement ids and the same access token, concurrently
with the others (§6.10). The partitions’ locations are in the statement document: on the
streamed path (§6.1), whose response is partition 0 from this server, a client that sent
the header reads the document (§6.3) for the locations of partitions 1 to k - 1.
6.3 Status: GET /warp/v1/sessions/{session_id}/statements/{statement_id}
Section titled “6.3 Status: GET /warp/v1/sessions/{session_id}/statements/{statement_id}”Returns the statement document. With Prefer: wait=N (capability long_poll) the
server MAY hold the response until the state changes or N seconds pass, whichever is
first. A statement that has finished (succeeded, failed, cancelled) can’t change,
so the server MUST answer at once.
6.4 Results: GET …/statements/{statement_id}/result
Section titled “6.4 Results: GET …/statements/{statement_id}/result”Query parameters: partition (default 0) and from_batch (default 0, counting that
partition’s batches). A partition the result doesn’t have is 404 with code
partition_not_found.
Returns 200 with an Arrow stream of that partition’s record batches, starting at
batch number from_batch (counting from 0). The stream always begins with the
schema message, which servers SHOULD send as soon as the schema is known rather than
with the first batch: an aggregate produces its first batch only at the end, and a
response that stays silent until then is cut by proxies’ idle timeouts. A client MAY request the result while the statement is still
running; the server streams batches as they are produced. If the statement has not
produced its result’s schema within the server’s wait limit, the answer is 409 with
code result_not_ready, and the client retries. A failed or cancelled statement’s
result request gets the statement’s own error; a statement that returns no rows gets
400, bad_request.
6.5 Retention and resumption
Section titled “6.5 Retention and resumption”A retained result can be read again, and from any batch, until it expires; an unretained result can be read once, from batch 0, while the statement’s session lives.
Warp-Retain-Result: true|falseon submit asks; without it the server decides (servers SHOULD retain forrespond-asyncsubmissions and SHOULD NOT for streamed submissions). A server MAY decline to retain a result it was asked to keep (for example one that keeps no results at all); it MUST NOT retain one it was asked not to. The statement document says what the server chose (retained) and until when (expires_at, once the statement has finished), and a client that wants to re-read a result checksretained.- Re-reading an unretained result, or
from_batchgreater than 0 on one, gets409with coderesult_not_resumable. - An expired result gets
410 Gonewith coderesult_expired. Expiry applies to reads that start after it: a server MUST deliver in full, to its end-of-stream marker, a read of a result (or of one of its partitions) that was already under way when the result expired. Deleting the statement (§6.8) or ending its session may still end such a read early, without the marker (§6.6). - Retained results are a resource the server MAY limit per principal; a statement
that would exceed the limit fails with
429and coderesult_quota_exceeded. - If the client disconnects while reading an unretained result, the server
SHOULD cancel the statement (
cancel_reason: client), including while the stream is waiting for the statement’s next batch. A retained result’s statement keeps running, and so does one that changes data (INSERT … RETURNING, …): the server MUST NOT undo a write because its reader went away; it runs to the end with its rows discarded. - A finished statement is not kept forever. A server MAY forget one once at least 5
minutes have passed since it finished and its retained result, if any, has expired,
or sooner when the session holds more than 1,000 finished statements (oldest first);
requests for a forgotten statement get
404with codestatement_not_found. Clients that are done with a statement SHOULD delete it (§6.8).
6.6 Errors during a result stream
Section titled “6.6 Errors during a result stream”Once a 200 response with an Arrow body has started, the status code cannot change.
If the statement fails mid-stream, the server ends the body without the Arrow
end-of-stream marker (in HTTP/1.1, by closing the connection or sending the final
chunk early). A client that reads an incomplete stream MUST treat the result as
failed and SHOULD fetch the statement document for the error. A complete stream
always ends with the end-of-stream marker.
6.7 Idempotent submission
Section titled “6.7 Idempotent submission”A client SHOULD send Idempotency-Key (a unique string, at most 255 characters) on
every request that creates a statement or writes data. If the server has already
accepted a request with the same key from the same principal within its
idempotency window (at least 24 hours, RECOMMENDED), it MUST NOT run it again: it
answers with the existing statement (the statement document with 200, or for
ingest the original response). A reused key with a different request body gets
422 with code idempotency_key_reused. This makes it safe to retry a request
whose response was lost, including DML.
6.8 Cancel and close
Section titled “6.8 Cancel and close”POST …/statements/{statement_id}/cancel:202. Cancelling a queued statement removes it from the queue; it never starts. Cancelling a finished statement is a no-op (202).DELETE …/statements/{statement_id}: the client is done with the statement. Its result is freed and its id forgotten (204). A query still running is cancelled; a statement that changes data runs to the end with its rows discarded, as when its reader goes away (§6.5). To stop a write, cancel it.POST /warp/v1/sessions/{session_id}/cancel: cancels every statement of the session that is queued or running when the request arrives;202with{"cancelled": ["q_…", …]}(possibly empty). This is for a client that has no statement id yet, because its submit’s answer hasn’t arrived (Prefer: wait, or a streamed submit still waiting for its result). A submit that reaches the server after the cancel isn’t affected.
6.9 Timeouts
Section titled “6.9 Timeouts”Warp-Statement-Timeout: <ms> limits how long the statement may run. When it
expires the server cancels the statement (cancel_reason: timeout, error code
statement_timeout). A server MAY enforce a maximum of its own.
6.10 Partitioned results
Section titled “6.10 Partitioned results”Capability: result_partitions. One TCP connection carries what one flow of the network
allows; a client that reads a large result over several connections at once can go
faster. It asks for that when it submits (or executes a prepared statement):
| Request header | Meaning |
|---|---|
Warp-Result-Partitions: <n> |
The client reads up to n partitions of the result at once (1 to 256). Without it, or with 1, the result has one partition. |
Warp-Result-Order: preserve or any |
any: the client does not need the rows in result order. Default preserve. |
The server MAY then split the result into k partitions, 1 ≤ k ≤ n; it keeps a
small result in one (a result that turns out to have fewer than two batches, for
example). A server SHOULD choose k by the result’s size, so that a result of a few
batches doesn’t open many connections that carry little: it MAY hold back the first
batches, for a bounded time, until the size shows. A client can therefore ask for as
many partitions as its link and cores can use, whatever the query. The statement document’s result lists all k partitions and, when k is
more than 1, says how the rows are spread over them in partition_order:
interleaved(the client asked forpreserve): the result’s batches are dealt round robin. Batchjof the result (counting from 0, in result order) is batchj div kof partitionj mod k. A client puts the result back in order by taking the next batch of partition 0, then of partition 1, …, then of partitionk - 1, and again from partition 0, until a partition has no next batch; then every partition has ended. Every partition has as many batches as partition 0 or one fewer, and no partition contains an empty batch. A query whoseORDER BYfixes the order keeps it this way.any(the client asked forany): each batch is in exactly one partition, and the rows’ order means nothing, within a partition or across partitions.
On the streamed path (§6.1) the 200 Arrow response is partition 0, and its headers say
how the result is split: when k is more than 1 the response MUST carry both
Warp-Result-Partitions: <k> and Warp-Result-Order: interleaved or any; for one
partition it carries neither. A client MUST NOT guess a missing Warp-Result-Order. The client reads partitions 1 to
k - 1 with GET …/statements/{statement_id}/result?partition=<i> (§6.4), each on its own connection, all at
once. Until the server has decided how to split a running statement’s result, its
document lists partition 0 only and has no partition_order; a client that reads the
result while the statement runs waits for partition_order or a finished statement.
Every partition is an Arrow stream with the result’s schema. A partition’s stream ends with the end-of-stream marker only once the statement has produced all of its rows successfully. If the statement fails or is cancelled, every partition stream still open ends without the marker (§6.6), and a partition requested afterwards gets the statement’s error. A client MUST read every partition to its end-of-stream marker before it treats the result as complete, and MUST read the partitions concurrently: the server produces an unretained result only as fast as its slowest partition is read, so reading one partition to its end before starting the next can stall the statement.
Retention (§6.5) applies to each partition: a retained result’s partitions can each be
re-read, and resumed with from_batch, until the result expires. An unretained
partition can be read once, from batch 0. If the reader of any partition of an
unretained result disconnects before its end, the server SHOULD cancel the statement,
and the other partitions then end without the marker. DELETE and cancel (§6.8) act on
the whole statement, every partition at once.
A server without the capability ignores both headers and returns one partition, so a client that sends them must still check how many partitions the result has.
7. Prepared statements
Section titled “7. Prepared statements”Capability: prepared_statements.
7.1 POST /warp/v1/sessions/{session_id}/prepared
Section titled “7.1 POST /warp/v1/sessions/{session_id}/prepared”Body {"sql": "INSERT INTO t VALUES (?, ?)"}. 201 with:
{ "prepared_id": "p_77a…", "parameter_schema": "<base64 Arrow schema>", "result_schema": "<base64 Arrow schema, or null>", "result_kind": "rows"}result_kind says what executing returns: "rows" (with result_schema, the rows’
schema, when the server can tell it before running), "update" (a count, no rows;
result_schema is null), or null when the server can only tell at execution.
Preparing MUST NOT run the statement.
7.2 POST …/prepared/{prepared_id}/execute
Section titled “7.2 POST …/prepared/{prepared_id}/execute”The body is either JSON {"parameters": [...]} (as in §6.1) or an Arrow stream
(Content-Type: application/vnd.apache.arrow.stream) whose schema matches
parameter_schema. Each row of the Arrow body is one execution, so a client can
run a batch of inserts in one request. A query with more than one parameter row
returns the concatenation of each execution’s rows, in order. A query run for an Arrow
body with no rows returns no rows (its schema, no batches), not a count.
The response is exactly as for §6.1, with the same headers (Accept, Prefer,
Idempotency-Key, …). Each execution creates a statement.
Servers MUST authorize each execution as the principal executes it, not once at prepare time: a privilege revoked between prepare and execute applies.
7.3 DELETE …/prepared/{prepared_id}
Section titled “7.3 DELETE …/prepared/{prepared_id}”204.
8. Bulk ingest
Section titled “8. Bulk ingest”Capability: ingest.
8.1 POST /warp/v1/sessions/{session_id}/ingest
Section titled “8.1 POST /warp/v1/sessions/{session_id}/ingest”Query parameters:
| Parameter | Required | Meaning |
|---|---|---|
table |
yes | Target table name |
catalog, schema |
no | Default: the session’s current catalog and schema |
mode |
no | create (fail if it exists), append (fail if it does not), replace, or create_append (default) |
temporary |
no | true to create a temporary table |
The body is an Arrow stream; it MAY be sent with chunked transfer encoding and any
size. The response is 200 with {"rows_ingested": 1000000} once every row is
written. An ingest is one transaction when the engine supports it: on failure, no
rows from the request remain.
The server MUST map each Arrow type in the upload to a column type that preserves
its values, and MUST refuse with 415 and code unsupported_arrow_type a type it
cannot preserve. It MUST NOT fall back to storing values as text.
Idempotency-Key (§6.7) makes a retried upload safe.
Deployment note: reverse proxies commonly cap request bodies (often at 1 MB) and
buffer them. A deployment that offers ingest must lift the body-size limit for this
path and SHOULD disable request buffering, or uploads fail with 413 after the whole
body has been sent.
8.2 Compression
Section titled “8.2 Compression”Two independent layers, both optional:
-
HTTP content coding. Standard
Accept-Encoding/Content-Encoding(gzip,zstd, …). Browsers decode it transparently. -
Arrow buffer compression. The Arrow IPC format’s own per-buffer compression (
BodyCompression), with either of the codecs Arrow defines:Name Arrow codec Capability lz4_frameLZ4_FRAMEipc_compression_lz4_framezstdZSTDipc_compression_zstdA server with a codec’s capability MUST be able to produce and read it.
A client accepts compression with the
Warp-IPC-Compressionrequest header: a comma-separated list of codec names in its order of preference, optionally ending innone(Warp-IPC-Compression: lz4_frame, zstd). The server uses the first codec in the list that it supports, ignoring names it doesn’t know; it sends uncompressed Arrow when the header is absent, names no codec it supports, or reachesnonefirst. The response’sWarp-IPC-Compressionheader names what it used:lz4_frame,zstdornone. The same header on each partition’s request (§6.10) applies to that partition. Within a compressed stream a server MAY leave a buffer uncompressed (Arrow’s uncompressed length-1) when compressing it would save little; clients MUST read such buffers.lz4_framecosts a fraction ofzstd’s CPU on both sides and compresses less;zstdsuits links where bandwidth is scarcer than CPU. Uploads (§7.2, §8) MAY use any codec whose capability the server lists; the stream’s own metadata says which.
Uncompressed Arrow MUST always be accepted.
8.3 Partitioned ingest
Section titled “8.3 Partitioned ingest”Capability: partitioned_ingest. One TCP connection carries what one network flow
allows; a client with a large upload can send it over several connections at once
into one ingest, which the server commits as one transaction.
- Open with
POST /warp/v1/sessions/{session_id}/ingestsand the target as a JSON body:{"table": …, "catalog": …, "schema": …, "mode": …, "temporary": …}(the fields mean what §8.1’s query parameters mean). The answer is201with{"ingest_id": "i_…", "state": "open", "parts": []}and aLocation. AnIdempotency-Keymakes a retried open safe (§6.7). - Upload parts with
POST …/ingests/{ingest_id}/parts/{part},parta whole number from 0 to 255 the client chooses, each body an Arrow stream as in §8.1. Parts are sent at once, each on its own connection. Every part’s stream MUST have the same schema; the first part to arrive decides it, and a part with another schema fails the ingest. The answer, once the server has read the whole part, is200with{"part": 3, "rows_received": 250000}. The rows are not visible yet. - Commit with
POST …/ingests/{ingest_id}/commitand{"part_count": n}: the parts are 0 to n-1. The server answers200with{"rows_ingested": …}once every row of every part is written and committed, atomically: in one transaction, as §8.1 does. If a part from 0 to n-1 has not finished (or another part number was used), the answer is409with codeingest_incompleteand the ingest stays open: the client can upload what is missing and commit again. A commit whose answer was lost is safe to send again: it answers with the same result. - Abort with
DELETE …/ingests/{ingest_id}:204, and nothing is written. An abort that arrives once the server is already committing may come too late to stop it; the ingest’s document then sayscommitted, and that is the outcome.
GET …/ingests/{ingest_id} returns the ingest’s document:
{"ingest_id": "i_…", "state": "committed", "parts": [{"part": 0, "rows_received": 250000}, {"part": 1, "rows_received": 250000}], "rows_ingested": 500000}| Field | When | Meaning |
|---|---|---|
ingest_id |
always | The ingest’s id (§2.3) |
state |
always | open, committing, committed, failed or aborted; the last three are terminal |
parts |
always | The parts whose upload has finished, each with its part number and rows_received |
rows_ingested |
committed |
The rows written, as the commit answered |
error |
failed |
The ingest’s error, a problem document (§11) |
All or nothing: if any part fails (its data is invalid, it can’t be written, its
connection breaks before its body ends), the ingest fails: no row of any part remains,
every request still running for it gets the error, and so do later part uploads and
the commit. A part’s rows may be written to the uncommitted transaction as they
arrive, so a part cut off in the middle can’t simply be sent again: the client starts
the ingest over. A part whose answer was lost is safe to send again with the same
Idempotency-Key: the server answers with the first upload’s answer without reading
the body again, and a part number that already finished, sent without the key, gets
409 with code ingest_part_exists.
The rows of different parts arrive in no defined order, and a client MUST NOT rely on the order of rows within the table after a partitioned ingest. A client that needs the upload’s order uses one part, or §8.1.
Every request names the session, so every part is bound to the session’s principal
(§9.2): another principal’s request gets 404 with code session_not_found, whatever
the ingest id. An ingest belongs to its session: closing the session aborts it, and a
server MAY abort an ingest that has had no request for a while (the server’s choice,
at least a minute) with code ingest_expired. While an ingest is open its session MAY
run nothing else: other statements on the session wait until it is committed or
aborted.
9. Security
Section titled “9. Security”9.1 Principals
Section titled “9.1 Principals”A principal is the identity an access token establishes: a user known to the server. The server decides what each principal may do (for example with SQL roles and grants); the protocol only requires that a session’s principal is fixed when the session is created and that every operation runs as that principal.
9.2 Handles are bound to their principal
Section titled “9.2 Handles are bound to their principal”Every session belongs to the principal that created it, and every statement,
prepared statement, ingest and retained result belongs to its session. On every request
the server MUST check that the token’s principal owns the session named in the URL. A
request naming another principal’s session gets 404 with code session_not_found,
exactly as for a session that does not exist, whatever the rest of the URL names, so ids
cannot be probed. Possessing an id grants nothing.
A principal is its identity and its authority: everything that decides what it may do,
such as its role, a scoped token’s scope, catalog limits carried in the token, its tenant,
or the identity provider and groups it signed in with. A user may hold several tokens of
different authority at once (a full token and a readonly or scoped one, say), and a
session runs with the authority of the token that created it. So a token with the same
user name but a different authority is another principal here: on the session it gets
404 session_not_found like any other principal, and cannot act through a session
that a stronger token of the same user opened. What changes with every sign-in without
changing authority (a token’s id, issue time or expiry) does not make another principal: a
client that signs in again reaches its sessions with the new token. Idempotency keys
(§6.7) are scoped to the principal in the same sense.
Administrative actions on other principals’ work (ending their session, cancelling their query) are done in SQL, under the server’s own authorization rules, not through these endpoints.
9.3 Results are governed data
Section titled “9.3 Results are governed data”A retained result was produced under its principal’s privileges. It MUST only be readable by that principal. If the server stores retained results outside its own memory (local disk, object storage), it MUST protect them as it protects the source data, and MUST delete them when they expire or their session ends.
9.4 Metadata follows authorization
Section titled “9.4 Metadata follows authorization”The metadata endpoints (§10) MUST return only the objects the principal may see, applying the same rules as SQL: inside a catalog the principal may see, a schema, table, view or column it holds no privilege on is left out, as are keys that name one. SQL’s own ways of listing the catalog (an engine’s catalog views and functions) SHOULD follow the same rules, so a client can’t read around them.
9.5 Browsers
Section titled “9.5 Browsers”- Servers MUST NOT send
Access-Control-Allow-Origin: *together withAccess-Control-Allow-Credentials. Cross-origin access SHOULD be an explicit allow-list of origins, off by default. - To an origin on the allow-list a server SHOULD answer (preflights included) with that
origin in
Access-Control-Allow-OriginandAccess-Control-Allow-Credentials: true. A browser presents the user’s client certificate (§4.4) on a cross-origin request only when the page asks for credentials (credentials: "include"), and then lets the page read the answer only if it carries both. CORS preflights carry no credentials (Fetch standard), and a browser may not present a certificate on one: a server that refuses handshakes without a certificate can fail the preflight, so for browsers on another origin, make certificates optional at the handshake. On the server’s own origin there is no preflight. - Every response MUST carry
X-Content-Type-Options: nosniff. Responses with query results MUST carryCache-Control: no-store. - Because credentials are bearer tokens in a header (never cookies), the protocol is not exposed to cross-site request forgery.
9.6 Client certificates
Section titled “9.6 Client certificates”- Verified chain only. An identity comes from the leaf certificate of a chain the server verified during the handshake against the CAs it trusts for clients, never from a certificate it merely received. Every CA trusted for clients can sign in any identity of the mapped kind: trust only CAs that issue client certificates for this purpose.
- Bound tokens (§4.4) make a stolen access token useless without the certificate’s
private key. A token from another grant is not bound, even when it was obtained over an
mTLS connection. A credential that is itself bound (a server’s scoped token presented as a
password, say) is exchanged only over the connection’s own certificate
(
certificate_mismatchotherwise). - Revocation takes effect for new connections at the handshake and, where the server re-checks open connections, on the next request of each. A server that requires CRLs refuses every certificate whose issuer’s CRL is missing or out of date.
- Renegotiation. A server MUST NOT let a client change its certificate on an open connection (TLS 1.2 renegotiation): the identity a connection presents is fixed at its handshake.
- Identity match (§4.4) keeps a password from being used on a connection that presents another user’s certificate, when a deployment wants the certificate to be a second factor rather than an alternative.
9.7 Browser sign-in
Section titled “9.7 Browser sign-in”- Flows are short-lived, bounded and single-use (§4.3): each expires (5 to 15 minutes), the server holds a bounded number (in all, and SHOULD per client address), a token is handed out once and a callback completes a flow once.
- Two secrets, kept apart. The poll secret collects the token and travels only in the
creation answer and the poll body; the
statesent to the identity provider finds the flow at the callback. Neither is the flow id, and the poll secret is never in a URL or a log. Servers compare poll secrets in constant time. - No token in a URL. Tokens are only in poll answers;
return_togets the flow id in the fragment, which browsers do not send to servers or inReferer. - No open redirect. The callback redirects only to a
return_tothat §4.3.3 allows. - PKCE and nonce (§4.3.4) keep an authorization code that leaks from the callback’s URL useless to anyone else, and an ID token from being replayed into another flow.
- Responses are not stored. Flow creation, polls and the callback carry
Cache-Control: no-store; the callback page runs no script and loads nothing. - Phishing (§4.3.4): a person who completes a sign-in someone else started gives that person their token; flows should be opened by the client that created them.
9.8 Error messages
Section titled “9.8 Error messages”Errors SHOULD help the user fix the problem without revealing what they may not see: a denied query names the privilege missing on objects the user named, not the existence of objects they cannot see.
10. Metadata
Section titled “10. Metadata”These endpoints serve driver metadata calls (ADBC GetObjects, JDBC
DatabaseMetaData). They are GET requests under
/warp/v1/sessions/{session_id}/metadata/ that return Arrow streams. Their result
schemas are exactly those of the matching Arrow Flight SQL commands, so existing
driver code that consumes Flight SQL metadata works unchanged. Filter patterns use
SQL LIKE syntax (%, _, \ escape), as in Flight SQL.
| Endpoint | Query parameters | Result schema (as Flight SQL) |
|---|---|---|
catalogs |
none | CommandGetCatalogs: catalog_name: utf8 not null |
db-schemas |
catalog, db_schema_filter_pattern |
CommandGetDbSchemas: catalog_name: utf8, db_schema_name: utf8 not null |
tables |
catalog, db_schema_filter_pattern, table_name_filter_pattern, table_types (comma-separated), include_schema |
CommandGetTables: catalog_name: utf8, db_schema_name: utf8, table_name: utf8 not null, table_type: utf8 not null [, table_schema: binary not null] |
table-types |
none | CommandGetTableTypes: table_type: utf8 not null |
primary-keys |
catalog, db_schema, table (optional: without it, every table) |
CommandGetPrimaryKeys: catalog_name: utf8, db_schema_name: utf8, table_name: utf8 not null, column_name: utf8 not null, key_name: utf8, key_sequence: int32 not null |
imported-keys |
catalog, db_schema, table (optional, as for primary-keys) |
CommandGetImportedKeys (13 columns, pk_catalog_name … delete_rule) |
exported-keys |
catalog, db_schema, table |
CommandGetExportedKeys (same columns) |
cross-reference |
pk_catalog, pk_db_schema, pk_table, fk_catalog, fk_db_schema, fk_table |
CommandGetCrossReference (same columns) |
type-info |
data_type |
CommandGetXdbcTypeInfo |
table_schema (when include_schema=true) is the table’s Arrow schema serialized as
an IPC message, with the same per-field metadata keys Flight SQL defines
(ARROW:FLIGHT:SQL:TYPE_NAME, …:PRECISION, …:SCALE, …:REMARKS, …). A field is
nullable unless the column can’t hold NULL (NOT NULL, primary key), and decimal
columns carry …:PRECISION and …:SCALE. …:PRECISION counts decimal digits for every
numeric type (10 for a 32-bit integer), as JDBC’s and ODBC’s column size does; a column’s
comment is …:REMARKS. Flight SQL has no key for a column’s default, so Warp SQL adds
WARP:SQL:COLUMN_DEFAULT: the default’s SQL text. Ordering follows the Flight SQL definitions.
GET /warp/v1/sessions/{session_id}/metadata/server-info returns JSON rather than
Flight SQL’s SqlInfo union: one object of facts about the server. The protocol’s keys
are Flight SQL’s SqlInfo names, taken exactly this way: drop a leading FLIGHT_SQL_,
then write the rest in lower case (FLIGHT_SQL_SERVER_NAME becomes server_name,
SQL_IDENTIFIER_QUOTE_CHAR becomes sql_identifier_quote_char). A value has the JSON
type of its SqlInfo type: a string, a boolean, a number (int32 and int64, bitmasks
included), an array of strings (a string list), or an object (an int32-to-int32-list map,
its keys written as decimal strings). A server includes the keys it knows; a client
treats a missing key as unknown, not as false. These are the keys of version 1:
| Key | Type | Meaning |
|---|---|---|
server_name |
string | The server’s name, as in discovery (§3.1) |
server_version |
string | The server’s version, as in discovery (§3.1) |
server_arrow_version |
string | The version of the Arrow library the server uses |
server_read_only |
boolean | The server accepts no writes |
sql_identifier_quote_char |
string | The character that quotes identifiers (") |
sql_transactions_supported |
boolean | Transactions are supported (with capability transactions, §5.5) |
Later versions add other SqlInfo names this way. A server’s own keys follow §2.7
(duckdb_version, acme_instance_id). The protocol’s name and version are in discovery
(§3.1), not here.
11. Errors
Section titled “11. Errors”Every error response is a problem document:
{ "type": "urn:warp-sql:error:permission_denied", "title": "Permission denied", "status": 403, "code": "permission_denied", "detail": "SELECT on table lake.raw.events requires a grant you do not have", "sqlstate": "42501", "request_id": "r_5d2…"}code is the stable, machine-readable part, and type is urn:warp-sql:error: followed
by it. title is a short, human-readable summary of the code (“Permission denied”), the
same for every error with that code; detail is for people too, says what happened in
this case, and may change. Clients decide on code, never on title or detail.
sqlstate (optional) is the SQL standard’s five-character class and subclass. Where
the standard has one, servers SHOULD send it, in particular 42P07 (the table already
exists) and 42P01 (the table doesn’t exist), which bulk ingest’s create and append
modes meet (§8.1) and which clients map to “already exists” and “not found”.
request_id SHOULD also appear in the server’s logs. Some codes add members:
reason for invalid_token (§4.2) and session_ended (§5.6), and cancel_reason for
statement_cancelled and statement_timeout (§6.2).
Statuses. A 4xx status means the request, or the statement it ran, ended because of
what it asked for: its SQL or data, a privilege it lacks, a limit, its timeout, a cancel.
A 5xx status means the server itself failed or can’t serve now. A statement’s own outcome
is never 5xx and never 408 (which says the server gave up waiting for the request, and
which clients and proxies may retry on their own): a division by zero is 422, not a
server fault, and proxies and monitoring that count 5xx as outages count only real ones.
| Code | Status | Meaning |
|---|---|---|
bad_request |
400 | Malformed request |
unknown_option, invalid_option_value |
400 | A session option the server doesn’t have, or a bad value (§5.3) |
invalid_return_to |
400 | A browser sign-in’s return_to that is not a path on the server’s own origin (§4.3.3) |
sql_error |
400 | The engine rejected the SQL (syntax, binding, missing object) |
unsupported_grant_type |
400 | A grant this server does not offer, such as client_certificate where certificate sign-in is off (§4.4) |
invalid_credentials |
401 | §4.1 |
invalid_token |
401 | §4.2 |
session_ended |
401 | §5.6 |
certificate_required, unknown_certificate_identity, certificate_revoked |
401 | §4.4 |
certificate_mismatch |
401 | A certificate-bound token on a connection without that certificate (§4.4) |
certificate_identity_mismatch |
401 | A sign-in for another identity than the connection’s certificate (§4.4) |
permission_denied |
403 | The principal lacks a privilege |
read_only |
403 | The server or session is read-only |
session_not_found, statement_not_found, prepared_not_found, ingest_not_found |
404 | No such handle, or not the caller’s (§9.2); statement_not_found also for a statement the server has forgotten (§6.5) |
flow_not_found |
404 | No such browser sign-in flow, a wrong poll secret, or one already collected or expired (§4.3.2) |
partition_not_found |
404 | The result has no partition with that index (§6.4) |
not_found |
404 | No such endpoint |
method_not_allowed |
405 | The endpoint exists but not for this method |
result_not_ready |
409 | The statement has no result schema yet (§6.4) |
result_not_resumable |
409 | §6.5 |
ingest_incomplete, ingest_part_exists |
409 | §8.3 |
statement_cancelled |
409 | The statement was cancelled (§6.2, §6.8); cancel_reason says why |
statement_timeout |
409 | The statement ran out of time and was cancelled (§6.9) |
result_expired |
410 | §6.5 |
ingest_expired |
410 | §8.3 |
payload_too_large |
413 | A JSON request body over the server’s limit |
unsupported_arrow_type |
415 | §8.1 |
session_elsewhere |
421 | §5.6 |
idempotency_key_reused |
422 | §6.7 |
execution_error |
422 | The statement failed while running: the engine could not complete what it asked (a division by zero, a constraint, a conversion) |
too_many_sessions, result_quota_exceeded, rate_limited |
429 | A limit was reached (with Retry-After when retrying can help) |
headers_too_large |
431 | §2.5 |
internal_error |
500 | The server failed in a way that is not the request’s doing (a bug, a broken invariant) |
not_implemented |
501 | The server does not implement this operation (browser sign-in on a server without oauth_browser, §4.3) |
server_draining |
503 | The server is shutting down; with Retry-After |
access_denied, identity_provider_error |
— | A failed browser sign-in’s error.code in the poll answer, as is invalid_credentials (§4.3.2); never a response’s status |
Codes a client makes up. A client that reports a failure it found itself, with no
problem document from the server (a broken connection, a response it can’t read), may
give it a code in the same place as the server’s. Such codes begin with client_
(client_transport_error), and servers never send a code that begins with client_.
11.1 Graceful shutdown
Section titled “11.1 Graceful shutdown”A server that is shutting down MUST stop accepting new sessions and statements
(503, server_draining, with Retry-After), report not_serving on health, let
running statements finish within its grace period, and then cancel what remains
(cancel_reason: server_shutdown).
12. Versioning
Section titled “12. Versioning”The major version is in the path (/warp/v1). Within a major version, changes are
additive only: new endpoints, new optional fields, new capabilities, new error codes, new
values of enumerated fields. A change that would break a v1 client gets a new major
version, and a server MAY serve several majors at once.
So that additions break nothing, clients MUST:
- ignore JSON fields, headers, capabilities and grant types they don’t know;
- treat an unknown error
codeby its HTTP status; - accept an unknown value of an enumerated field rather than fail: an unknown statement,
ingest or browser sign-in flow
stateis not terminal (keep polling; later versions add only states that are not terminal, and every terminal outcome stayssucceeded,failed,cancelled,committed,abortedorcomplete); an unknownreasonorcancel_reasonis a generic one (the token, session or statement is gone, for no reason the client can name), and so is an unknown failed flow’serror.code; an unknowntransactionvalue is reported as unknown; an unknown healthreasonis a genericnot_serving.
A value that changes how a client must read a response (a new result.kind, a new
partition_order, a new compression codec) reaches only a client that asked for it. Any
new request field, header or header value that asks for new behaviour comes with a
capability: servers ignore what they don’t know (§2.4), so a client must not rely on a
server older than the feature to refuse it.
Appendix A. Possible future additions
Section titled “Appendix A. Possible future additions”Each of these would be an addition under §12, behind a capability of its own; none changes what a version 1 client sees.
- Upload resumption. Ingest that survives a broken connection without starting over (append chunks at offsets, then commit). Partitioned ingest (§8.3) already splits a large upload into parts that each go over their own connection.
- Result statistics. Per-column statistics or a query-profile handle in the statement document.
- Push notification. A server-sent-events stream of state changes, for clients that would otherwise long-poll (§6.3).
- Forwarded client certificates. A defined way for a TLS-terminating proxy that verified a client certificate to pass it on, with the thumbprint checks of bound tokens (§4.4). Until then a server trusts no forwarded certificate unless it is configured to (§4.4).
- Per-partition tokens. A short-lived token of its own for each remote partition
(§6.2,
result_locations), instead of the client’s access token.
The conformance suite (conformance/) tests the MUSTs a server can be tested for, and
its README lists the ones it doesn’t test yet.
Appendix B. Endpoints
Section titled “Appendix B. Endpoints”Every endpoint of version 1, under the base path (§2.1). The OpenAPI document (§3.3) has
exactly these; the conformance suite checks that the two lists agree. … stands for
/warp/v1/sessions/{session_id}.
| Method | Path | Section | Capability |
|---|---|---|---|
GET |
/warp |
§3.1 | |
GET |
/warp/v1/health |
§3.2 | |
GET |
/warp/v1/openapi.json |
§3.3 | openapi |
POST |
/warp/v1/auth/token |
§4.1 | |
POST |
/warp/v1/auth/oauth/flows |
§4.3.1 | oauth_browser |
POST |
/warp/v1/auth/oauth/flows/{flow_id} |
§4.3.2 | oauth_browser |
GET |
/warp/v1/auth/oauth/callback |
§4.3.4 | oauth_browser |
POST |
/warp/v1/sessions |
§5.1 | |
GET |
… |
§5.2 | |
PATCH |
… |
§5.3 | |
DELETE |
… |
§5.4 | |
POST |
…/commit |
§5.5 | transactions |
POST |
…/rollback |
§5.5 | transactions |
POST |
…/cancel |
§6.8 | |
POST |
…/statements |
§6.1 | |
GET |
…/statements/{statement_id} |
§6.3 | |
DELETE |
…/statements/{statement_id} |
§6.8 | |
GET |
…/statements/{statement_id}/result |
§6.4 | |
POST |
…/statements/{statement_id}/cancel |
§6.8 | |
POST |
…/prepared |
§7.1 | prepared_statements |
POST |
…/prepared/{prepared_id}/execute |
§7.2 | prepared_statements |
DELETE |
…/prepared/{prepared_id} |
§7.3 | prepared_statements |
POST |
…/ingest |
§8.1 | ingest |
POST |
…/ingests |
§8.3 | partitioned_ingest |
GET |
…/ingests/{ingest_id} |
§8.3 | partitioned_ingest |
DELETE |
…/ingests/{ingest_id} |
§8.3 | partitioned_ingest |
POST |
…/ingests/{ingest_id}/parts/{part} |
§8.3 | partitioned_ingest |
POST |
…/ingests/{ingest_id}/commit |
§8.3 | partitioned_ingest |
GET |
…/metadata/catalogs |
§10 | |
GET |
…/metadata/db-schemas |
§10 | |
GET |
…/metadata/tables |
§10 | |
GET |
…/metadata/table-types |
§10 | |
GET |
…/metadata/primary-keys |
§10 | |
GET |
…/metadata/imported-keys |
§10 | |
GET |
…/metadata/exported-keys |
§10 | |
GET |
…/metadata/cross-reference |
§10 | |
GET |
…/metadata/type-info |
§10 | |
GET |
…/metadata/server-info |
§10 |