Query data
The whole query body of the Developer API, from dimensions, measures and filters to paging, counts, totals and time zones, and how to read the answer.
This page covers everything you can send to the query endpoint and everything it sends back. The key needs the data.query scope, and its person's role needs data.export.
POST /v1/developer/datasets/{id}/query HTTP/1.1
Host: api.usehalo.net
Authorization: Bearer hak_…
Content-Type: application/jsonThe body
| Field | Type | Means | Default |
|---|---|---|---|
dimensions | array | Groupings, outermost first | None |
measures | array | Numbers to aggregate per group | None |
filters | array | Conditions every row must meet | None |
sorts | array | Row order, in priority order | See Sorts |
limit | integer | Most rows to return, 1 to 1,000 | 100 |
offset | integer | Rows to skip | 0 |
keyset | boolean | Page by rows rather than by skipping them | false |
cursor | string | A previous window's stats.nextCursor | None |
timeZone | string | The IANA zone dates group and relative ranges resolve in | The organization's |
countRows | boolean | Also count every row in stats.total | false |
totals | boolean | Also aggregate the measures over every row in totals | false |
A query needs at least one dimension or measure. The path names the dataset, so the body has no specVersion or datasetId, and it always reads the dataset's current version. pivot, groupRows, goals and parameters aren't available here. Any field not in this table gets 422 validation_failed.
Every result row is one group of the dimensions. Measures alone give one row, and dimensions alone list their distinct values. To read something close to raw rows, group by fields that are unique together.
Dimensions
{ "field": "order_date", "grain": "month", "alias": "month" }| Key | Means |
|---|---|
field | The field's name, from GET /v1/developer/datasets/{id} |
grain | For date and timestamp fields: group by this period. The column key gets __grain, such as order_date__month |
binWidth | For number fields: group into ranges this wide. Each value is its range's start. The column key gets __bin |
alias | The result column's key, in place of the generated one |
A dimension takes a grain or a width, not both. A calculation over groups can't be a dimension.
| Grain | Groups by |
|---|---|
minute, hour | That period. Timestamp fields only |
day, month, quarter, year | That period |
week | Weeks from the organization's week start |
iso_week | Weeks from Monday |
fiscal_quarter, fiscal_year | Periods from the organization's fiscal year start |
Each value is its period's start: a date such as 2026-07-01, or a timestamp for minute and hour.
Measures
{ "field": "revenue", "aggregate": "avg", "alias": "average_order" }| Key | Means |
|---|---|
field | The field's name |
aggregate | How to combine values. Left out, the field's default applies. One that differs from the default adds __aggregate to the column key, such as revenue__avg |
alias | The result column's key, in place of the generated one |
| Aggregate | Combines values by | Field type |
|---|---|---|
sum, avg | Adding them, averaging them | Number |
median, p25, p75 | Percentiles | Number |
min, max | Lowest, highest | Any |
count | Counting rows | Any |
count_distinct | Counting unique values | Any |
Any field can be a measure if you name an aggregate, such as { "field": "region", "aggregate": "count_distinct" }. A field that's already a calculation can't take one.
Filters
A filter is a field, an op, and values or range. Every filter in the list must hold.
op | Takes | Example |
|---|---|---|
eq, neq, gt, gte, lt, lte | One item in values | { "field": "revenue", "op": "gte", "values": [1000] } |
in, not_in | 1 to 1,000 items in values | { "field": "region", "op": "in", "values": ["North", "South"] } |
between | Two items in values, inclusive | { "field": "orders", "op": "between", "values": [10, 50] } |
contains, starts_with | One item in values, ignoring case | { "field": "channel", "op": "contains", "values": ["online"] } |
is_null, not_null | Nothing | { "field": "channel", "op": "not_null" } |
date_range | range | See below |
Values are converted to the field's type. Write dates as
2026-07-04, and timestamps as2026-07-04T09:30.Missing values.
eqand the other comparisons refusenull, so useis_null.inwith anullin its list also matches missing values.not_inkeeps rows with a missing value unless its list has anull.Filters on measures apply to each group's aggregate rather than to rows, using the field's default aggregate.
Date ranges
date_range works on date and timestamp fields. An absolute range includes both its days.
{ "field": "order_date", "op": "date_range", "range": { "kind": "absolute", "start": "2026-01-01", "end": "2026-06-30" } }{ "field": "order_date", "op": "date_range", "range": { "kind": "relative", "preset": "last_n_days", "n": 30 } }| Preset | Covers |
|---|---|
today, yesterday | That day |
last_n_days | The n days ending yesterday |
last_n_weeks, last_n_months | The n whole weeks or months before this one |
this_week | This week's start through today |
month_to_date, quarter_to_date, year_to_date | The period's start through today |
last_week, last_month, last_quarter, last_year | The whole previous period |
rolling_12_months | The 12 whole months before this one |
fiscal_quarter_to_date, fiscal_year_to_date | The fiscal period's start through today |
last_fiscal_quarter, last_fiscal_year | The whole previous fiscal period |
n is 1 to 3,660. Weeks follow the organization's week start, and fiscal periods its fiscal year start. On a timestamp field, a range covers whole days in the query's time zone.
Sorts
{ "field": "revenue", "direction": "desc" }direction is asc or desc. field is a result column's key, or a field name, which sorts by the first column over that field. It has to be in the query.
With no sorts, the first date or grain column runs forward. If there's none, the first measure runs highest first. If there's no measure, the first column runs lowest first.
Paging
One request returns at most 1,000 rows. Asking for more gets 422 validation_failed. stats.hasMore says more follow, and there are two ways to ask for them.
offset | keyset | |
|---|---|---|
| Good for | Jumping to a page, short results | Reading everything, deep results |
| How it reads | The source produces and throws away every row before the window, so deep windows get slower | Each window continues after the last row of the one before |
| Order | Rows that tie on your sort may move between windows | Every row has one place, since the remaining dimensions are added to the sort as tiebreakers |
Each row you get back counts toward the daily row budget, whichever you use.
Keyset paging
Send keyset: true on every window. Each window with more to come has a stats.nextCursor. Pass it back as cursor, along with offset set to where that window ended.
import os
import requests
url = f"https://api.usehalo.net/v1/developer/datasets/{os.environ['DATASET_ID']}/query"
headers = {"Authorization": f"Bearer {os.environ['HALO_API_KEY']}"}
query = {
"dimensions": [{"field": "order_date"}, {"field": "region"}, {"field": "channel"}],
"measures": [{"field": "revenue"}, {"field": "orders"}],
"limit": 1000,
"keyset": True,
}
offset, after = 0, None
while True:
body = {**query, "offset": offset, **({"cursor": after} if after else {})}
response = requests.post(url, headers=headers, json=body, timeout=330)
response.raise_for_status()
page = response.json()
for row in page["rows"]:
print(row)
if not page["stats"]["hasMore"]:
break
offset += page["stats"]["rowCount"]
after = page["stats"]["nextCursor"]This stops on any error. To retry the ones worth retrying, see Snippets.
A cursor belongs to one query, sort and offset. Change any of them and Halo ignores it and uses offset, so the window is still right, only slower. The last window has no cursor. Treat a cursor as opaque, and send it back as it came.
Count and totals
Each of these runs a second query.
countRows: true puts the number of rows the query returns, ignoring limit and offset, in stats.total. Results too big to count cheaply can get the source's estimate instead of an exact count: on sources that can estimate, such as PostgreSQL, from about a million rows. stats.totalEstimated is true when it is one.
totals: true adds a top-level totals object: the measures over every row, by column key. Averages and distinct counts are exact, rather than added up from the rows you see.
{ "totals": { "revenue": 431970.75, "orders": 1824 } }If either fails, the rows still come back and warnings says why. With a filter on a measure, totals that don't add up from the groups (such as averages) are null, with a warning. totals does nothing in a query with no measures.
Time zones
timeZone takes an IANA name such as America/Denver. It decides where date grains fall, what relative ranges mean, and the zone timestamps come back in. Left out, the organization's applies. stats.timeZone says which one the answer used. An unknown zone gets 422 invalid_query.
Set it when your job shouldn't move if someone changes the organization's zone.
Read the answer
{
"columns": [
{ "key": "order_date__month", "field": "order_date", "label": "Order date", "role": "dimension", "type": "date", "grain": "month" },
{ "key": "revenue", "field": "revenue", "label": "Revenue", "role": "measure", "type": "number", "aggregate": "sum", "format": "currency:USD" }
],
"rows": [
["2026-09-01", 412880.1],
["2026-10-01", 431970.75]
],
"stats": {
"durationMs": 212,
"rowCount": 2,
"hasMore": false,
"cached": true,
"computedAt": "2026-11-03T14:58:02Z",
"timeZone": "America/Denver"
},
"warnings": []
}rows and columns
rows holds an array per row, in the order of columns. Turn them into objects by key:
const objects = rows.map((row) =>
Object.fromEntries(columns.map((column, i) => [column.key, row[i]])),
);| Column field | Means |
|---|---|
key | The column's name in this result: the alias, or the field with any __grain, __bin or __aggregate suffix |
field | The dataset field it reads |
label | The field's label, for people |
role | dimension or measure |
type | string, number, integer, boolean, date or timestamp |
grain, binWidth, aggregate | How it was grouped or combined, when it was |
format | How Halo displays values, such as percent or currency:USD, when the field sets one |
semanticType | What the values are, when the field says |
type | Values look like |
|---|---|
string | Text |
number, integer | JSON numbers |
boolean | true or false |
date | 2026-07-04 |
timestamp | 2026-07-04T09:30:00, local to stats.timeZone, with no offset |
Any value can be null.
stats and warnings
| Stat | Means |
|---|---|
durationMs | How long the query took |
rowCount | Rows in this answer |
hasMore | More rows follow |
nextCursor | Marks the last row, for cursor. On keyset requests with more rows |
total | Every row the query returns. When you asked for countRows |
totalEstimated | true when total is the source's estimate rather than a count |
cached | The result came from Halo's cache rather than the source |
computedAt | When the source answered |
timeZone | The zone the query used |
A dataset's cache lifetime decides how long cached answers last. The API has no way to skip the cache.
warnings is a list of sentences for people, and is empty when there's nothing to say. Log them.
Long queries
Each plan bounds how long a query may run: see Rate limits. Past that you get 504 query_timeout. Closing the connection cancels the query at the source, so set your client's timeout a little above your plan's, and give up by closing rather than leaving requests running.
A chart's rows
GET /v1/developer/charts/{id}/data runs a saved chart's own query against its dataset, as the person, and answers in the same shape. It takes no body, returns at most 1,000 rows, and doesn't page.