# 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](https://dev.nominal.io/sql-reference/rest-api/query.md#limits). |
| `result_format` | enum | No | Encoding for the response body. Defaults to an Arrow stream. See [Result formats](https://dev.nominal.io/sql-reference/rest-api/query.md#result-formats). |

```shell
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](https://dev.nominal.io/sql-reference/rest-api/errors.md#error-response-schema) 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:

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

**CSV**

```shell
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:

```text
"ts","channel","value"
"2026-01-01T12:00:00.107000064Z","channel_a",0
```

**JSON lines**

```shell
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:

```json
{"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](https://dev.nominal.io/sql-reference/rest-api/export.md) doesn’t use this field. It takes its own `format`.

## Converting to a dataframe

An Arrow table converts to [pandas](https://pandas.pydata.org/) or [Polars](https://pola.rs/) in one call, and both preserve the nanosecond timestamps the telemetry tables return:

**pandas**

```python
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.

**Polars**

```python
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.

**Arrow only**

```python
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:

```python
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:

```python
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](https://dev.nominal.io/sql-guides/writing-queries.md#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](https://dev.nominal.io/quickstart/quick/quick-sql.md#in-the-nominal-app) 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](https://dev.nominal.io/sql-reference/rest-api/export.md), which runs with a 120 second execution budget and no result-size cap, and either finishes or fails, never truncates.

## Next steps

- [Export](https://dev.nominal.io/sql-reference/rest-api/export.md): retrieve a full result set as a file.
- [Error handling](https://dev.nominal.io/sql-reference/rest-api/errors.md): error codes and response shape.
