Query#
POST /sql/v1/query
Runs a query and streams the result back in the specified format.
Request#
Field |
Type |
Required |
Notes |
|---|---|---|---|
|
string |
Yes |
The |
|
string |
Yes |
Workspace to resolve datasets against. |
|
integer |
No |
Cap on the number of rows returned, between 0 and 5000. A value of |
|
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 |
|---|---|
|
Arrow IPC stream. The default, and what you get when the field is omitted. |
|
One JSON object per line, keyed by column name. |
|
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()andas_py()raiseValueError: Nanosecond resolution temporal type ... is not safely convertible to microseconds. Becauseto_pylist()converts every column, onetscolumn makes the whole call fail.cast(pa.timestamp("us"))raisesArrowInvalid: would lose data. Truncating requirescast(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 |
Result size |
1 GB |
The query fails with |
Metadata rows |
4 MiB of rows read from the metadata tables |
The query fails with |
Request rate |
Set per organization for each deployment |
The request fails with HTTP 429 and |
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#
Export: retrieve a full result set as a file.
Error handling: error codes and response shape.