Query#

POST /sql/v1/query

Runs a query and streams the result back in the specified format.

Request#

Field

Type

Required

Notes

query

string

Yes

The SELECT statement. Maximum 100,000 characters.

workspace_rid

string

Yes

Workspace to resolve datasets against.

max_rows

integer

No

Cap on the number of rows returned, between 0 and 5000. A value of 0 returns only the result’s schema, with no rows. Omit it for an uncapped result, bounded only by the service limits.

result_format

enum

No

Encoding for the response body. Defaults to an Arrow stream. See Result formats.

curl --request POST '{API_URL}/sql/v1/query' \
  --header 'Authorization: Bearer <your-api-key>' \
  --header 'Content-Type: application/json' \
  --data '{
    "query": "SELECT ts, value FROM points_double WHERE dataset_rid = '\''<dataset-rid>'\'' ORDER BY ts",
    "workspace_rid": "<workspace-rid>"
  }'

Response#

The response body is the encoded result. A successful response carries the query’s ID in the x-nominal-query-id header, and an error response carries it as parameters.sqlQueryId. Include it when reporting a slow or failed query.

Unless you ask for something else, the body is a raw Arrow payload. Arrow is binary, not JSON, so write it to a file or read it as bytes rather than letting it print to a terminal.

Read it with an Arrow reader. In Python:

import pyarrow as pa

with pa.OSFile("result.arrow", "rb") as source:
    table = pa.ipc.open_stream(source).read_all()

open_stream handles both a single message and a multi-message stream, so no deframing is needed.

Result formats#

result_format selects how the response body is encoded:

Value

Body

SQL_SERVICE_QUERY_RESULT_FORMAT_ARROW_STREAM

Arrow IPC stream. The default, and what you get when the field is omitted.

SQL_SERVICE_QUERY_RESULT_FORMAT_JSON_LINES

One JSON object per line, keyed by column name.

SQL_SERVICE_QUERY_RESULT_FORMAT_CSV

CSV with a header row.

Prefer Arrow when you are loading results into pandas, Polars, or anything else that speaks it, since Arrow carries the column types with the data rather than leaving you to infer them.

The text formats need no Arrow reader, which makes them the simpler choice for a shell pipeline or a quick look at a result. They give up the column types but keep the values, including the full nanosecond precision on ts.

curl --request POST '{API_URL}/sql/v1/query' \
  --header 'Authorization: Bearer <your-api-key>' \
  --header 'Content-Type: application/json' \
  --data '{
    "query": "SELECT ts, channel, value FROM points_double WHERE dataset_rid = '\''<dataset-rid>'\'' ORDER BY ts LIMIT 10",
    "workspace_rid": "<workspace-rid>",
    "result_format": "SQL_SERVICE_QUERY_RESULT_FORMAT_CSV"
  }'

A header row, then one row per result:

"ts","channel","value"
"2026-01-01T12:00:00.107000064Z","channel_a",0
curl --request POST '{API_URL}/sql/v1/query' \
  --header 'Authorization: Bearer <your-api-key>' \
  --header 'Content-Type: application/json' \
  --data '{
    "query": "SELECT ts, channel, value FROM points_double WHERE dataset_rid = '\''<dataset-rid>'\'' ORDER BY ts LIMIT 10",
    "workspace_rid": "<workspace-rid>",
    "result_format": "SQL_SERVICE_QUERY_RESULT_FORMAT_JSON_LINES"
  }'

One JSON object per line, keyed by column name. The lines sit in no enclosing array and carry no separating commas, so parse them one at a time rather than as a single document:

{"ts":"2026-01-01T12:00:00.107000064Z","channel":"channel_a","value":0}
{"ts":"2026-01-01T12:00:00.144999936Z","channel":"channel_b","value":7.114995682427262}

Timestamps in both formats are ISO 8601 in UTC.

Export doesn’t use this field. It takes its own format.

Converting to a dataframe#

An Arrow table converts to pandas or Polars in one call, and both preserve the nanosecond timestamps the telemetry tables return:

df = table.to_pandas()

print(df.dtypes)
print(df.groupby("channel")["value"].sum())

ts arrives as datetime64[ns], so df["ts"][0] is a pd.Timestamp carrying all nine digits.

import polars as pl

df = pl.from_arrow(table)

print(df.schema)
print(df.group_by("channel").agg(pl.col("value").sum()).sort("channel"))

ts arrives as Datetime(time_unit="ns"). pl.from_arrow is zero-copy where the types allow it.

import pyarrow.compute as pc

channels = table.column("channel")
values = table.column("value")

for channel in sorted(pc.unique(channels).to_pylist()):
    total = pc.sum(values.filter(pc.equal(channels, channel))).as_py()
    print(channel, total)

Staying in Arrow avoids a conversion and keeps operations vectorized, at the cost of a less familiar API.

Converting to a dataframe is also the simplest way to sidestep the timestamp handling described next.

Reading timestamps#

If you are reading values straight out of the Arrow table rather than converting to a dataframe, the ts column needs care. Arrow returns it at nanosecond precision (timestamp[ns] in the schema), and Python’s datetime only resolves to microseconds, so the obvious ways of reading it fail:

  • table.to_pylist() and as_py() raise ValueError: Nanosecond resolution temporal type ... is not safely convertible to microseconds. Because to_pylist() converts every column, one ts column makes the whole call fail.

  • cast(pa.timestamp("us")) raises ArrowInvalid: would lose data. Truncating requires cast(pa.timestamp("us"), safe=False).

Read the scalar’s .value to get raw nanoseconds since the Unix epoch, then convert:

import datetime

timestamps = table.column("ts")
seconds = timestamps[0].value / 1e9
moment = datetime.datetime.fromtimestamp(seconds, datetime.timezone.utc)
print(moment.isoformat())

Dividing by 1e9 loses precision below the microsecond. To keep every digit, split the integer instead:

seconds, nanos = divmod(timestamps[0].value, 1_000_000_000)
moment = datetime.datetime.fromtimestamp(seconds, datetime.timezone.utc)
print(f"{moment:%Y-%m-%dT%H:%M:%S}.{nanos:09d}Z")

None of this applies if you convert to a dataframe first, since both pandas and Polars represent nanoseconds natively.

Limits#

A query is bound by the following limits.

Limit

Value

When exceeded

Execution time

2 minutes

The query fails with SQL_ERROR_QUERY_TIMEOUT.

Result size

1 GB

The query fails with SQL_ERROR_RESULT_SIZE_EXCEEDED telling you to add a LIMIT, narrow the filters, or use export.

Metadata rows

4 MiB of rows read from the metadata tables

The query fails with SQL_ERROR_INVALID_QUERY telling you to narrow the run, asset, or dataset filters. See Joins.

Request rate

Set per organization for each deployment

The request fails with HTTP 429 and SQL_ERROR_RESOURCE_EXHAUSTED.

To bound the number of rows returned, put a LIMIT in the query or set max_rows on the request. max_rows accepts 0 through 5000 (0 returns only the schema, with no rows); the SQL console sends the maximum on every query, which is why its results pane stops at 5000 rows. Rows past max_rows are dropped without notice. Neither cap applies unless you ask for it: omit max_rows and a direct REST API call returns every row the limits above allow.

The query runs to completion before any of the result is sent, so a query that runs for 20 seconds returns its entire result at the 20 second mark. Result sets near these limits belong on export, which runs with a 120 second execution budget and no result-size cap, and either finishes or fails, never truncates.

Next steps#