Skip to content

Specification

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.


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.
  • 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_certificate grant, §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 alert certificate_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.
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).

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).

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)

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.

These endpoints need no authentication.

{
"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).

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.

Exchanges a credential for an access token. The credential travels in a JSON body, never in a URL.

POST /warp/v1/auth/token
Content-Type: application/json
{"grant_type": "password", "username": "joe", "password": "s3:cret"}
{"access_token": "eyJhbGciOi…", "token_type": "Bearer", "expires_in": 3600}

Grant types:

  • password: username and password. 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 capability mtls_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.

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:

  1. The client creates a flow and receives its id, a poll secret and the URL where the person signs in.
  2. 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.
  3. 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.

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_id is an identifier (§2.3). poll_secret is at least 128 bits of randomness, in the same alphabet. The poll secret appears in this answer only: it MUST NOT be part of authorize_url, the callback, any redirect, the flow’s Location, 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_url is 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.

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 Other
Location: /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 by state only;
  • PKCE (RFC 7636) with code_challenge_method=S256: the server keeps the verifier and sends it with the code;
  • for OpenID Connect, a nonce per 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/token
Content-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.

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.

{"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.

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.

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.

Ends the session, cancels its running statements, and frees its retained results. 204.

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.

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.

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.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…/statements
Authorization: Bearer eyJ…
Content-Type: application/json
Accept: application/json
Prefer: wait=5
Idempotency-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.stream and no respond-async, a query’s result streams in the response body: 200, Content-Type: application/vnd.apache.arrow.stream, and Warp-Statement-Id naming the statement. A statement that returns no rows (DML, DDL) answers 200 with a JSON statement document (§6.2) instead; clients MUST check Content-Type.
  • Status document. Otherwise the server answers 201 with the statement document (§6.2) and Location: /warp/v1/sessions/{session_id}/statements/{statement_id}. With Prefer: wait=N the server MAY hold the response up to N seconds for the statement to finish; with Prefer: respond-async it 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).

{
"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.

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|false on submit asks; without it the server decides (servers SHOULD retain for respond-async submissions 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 checks retained.
  • Re-reading an unretained result, or from_batch greater than 0 on one, gets 409 with code result_not_resumable.
  • An expired result gets 410 Gone with code result_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 429 and code result_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 404 with code statement_not_found. Clients that are done with a statement SHOULD delete it (§6.8).

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.

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.

  • 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; 202 with {"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.

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.

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 for preserve): the result’s batches are dealt round robin. Batch j of the result (counting from 0, in result order) is batch j div k of partition j mod k. A client puts the result back in order by taking the next batch of partition 0, then of partition 1, …, then of partition k - 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 whose ORDER BY fixes the order keeps it this way.
  • any (the client asked for any): 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.

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.

204.

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.

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_frame LZ4_FRAME ipc_compression_lz4_frame
    zstd ZSTD ipc_compression_zstd

    A server with a codec’s capability MUST be able to produce and read it.

    A client accepts compression with the Warp-IPC-Compression request header: a comma-separated list of codec names in its order of preference, optionally ending in none (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 reaches none first. The response’s Warp-IPC-Compression header names what it used: lz4_frame, zstd or none. 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_frame costs a fraction of zstd’s CPU on both sides and compresses less; zstd suits 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.

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.

  1. Open with POST /warp/v1/sessions/{session_id}/ingests and the target as a JSON body: {"table": …, "catalog": …, "schema": …, "mode": …, "temporary": …} (the fields mean what §8.1’s query parameters mean). The answer is 201 with {"ingest_id": "i_…", "state": "open", "parts": []} and a Location. An Idempotency-Key makes a retried open safe (§6.7).
  2. Upload parts with POST …/ingests/{ingest_id}/parts/{part}, part a 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, is 200 with {"part": 3, "rows_received": 250000}. The rows are not visible yet.
  3. Commit with POST …/ingests/{ingest_id}/commit and {"part_count": n}: the parts are 0 to n-1. The server answers 200 with {"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 is 409 with code ingest_incomplete and 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.
  4. 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 says committed, 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.

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.

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.

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.

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.

  • Servers MUST NOT send Access-Control-Allow-Origin: * together with Access-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-Origin and Access-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 carry Cache-Control: no-store.
  • Because credentials are bearer tokens in a header (never cookies), the protocol is not exposed to cross-site request forgery.
  • 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_mismatch otherwise).
  • 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.
  • 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 state sent 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_to gets the flow id in the fragment, which browsers do not send to servers or in Referer.
  • No open redirect. The callback redirects only to a return_to that §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.

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.

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.

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_.

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).

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 code by its HTTP status;
  • accept an unknown value of an enumerated field rather than fail: an unknown statement, ingest or browser sign-in flow state is not terminal (keep polling; later versions add only states that are not terminal, and every terminal outcome stays succeeded, failed, cancelled, committed, aborted or complete); an unknown reason or cancel_reason is a generic one (the token, session or statement is gone, for no reason the client can name), and so is an unknown failed flow’s error.code; an unknown transaction value is reported as unknown; an unknown health reason is a generic not_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.


Each of these would be an addition under §12, behind a capability of its own; none changes what a version 1 client sees.

  1. 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.
  2. Result statistics. Per-column statistics or a query-profile handle in the statement document.
  3. Push notification. A server-sent-events stream of state changes, for clients that would otherwise long-poll (§6.3).
  4. 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).
  5. 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.

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