> For the complete documentation index, see [llms.txt](https://docs.butlr.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.butlr.io/historical-occupancy/reporting-api-overview/query-limits.md).

# Query Limits

On **August 3, 2026**, the Reporting API moved to a new tiered storage backend. The endpoint and request format are unchanged, and the response format is unchanged with one exception: calibrated traffic queries (`"calibrated": "true"`) now label every row with the requested measurement name instead of the legacy `aggregator_occupancy` / `aggregator_headcount` pair — see the [changelog](/changelog.md). Queries are now validated against the limits below. A request outside these limits returns a `400` error describing what to change (previously, oversized queries often failed with an unexplained `500`).

{% hint style="success" %}
**Rule of thumb:** `1h` and `1d` intervals work for any time range, at any data age. When in doubt, use those.
{% endhint %}

### Supported intervals

`window.every` must be one of: `1m`, `5m`, `15m`, `30m`, `1h`, `1d`. Other values (e.g. `10m`, `2h`) are rejected.

### Maximum time range per interval

| Interval    | Maximum range (`stop` − `start`) |
| ----------- | -------------------------------- |
| `1m`        | 3 days                           |
| `5m`        | 14 days                          |
| `15m` `30m` | 90 days                          |
| `1h`        | 180 days                         |
| `1d`        | unlimited                        |

For longer periods, use a coarser interval or split the period into multiple queries.

### Maximum response size

A single response may not exceed **10 MB**. This limit is enforced by the API gateway in front of `api.butlr.io` (an AWS platform limit) and cannot be raised per customer.

A response over the limit does not come back as a normal validation error. The query itself succeeds, but the gateway discards the response and replies with HTTP `500` and this body, with no `errors` array:

```json
{ "message": "Internal server error" }
```

If a large query fails this way while a smaller query with the same filters succeeds, the response was too large.

**Estimating response size.** Each row in `data` is roughly 400–500 bytes, so 10 MB is about **20,000 rows**. A query returns one row per entity (room, zone, floor) per interval:

```
rows ≈ entities × (time range ÷ window.every)
```

For example, a `1m` query over 24 hours returns 1,440 rows per room, so about 14 rooms fit in one response. Over 3 days (4,320 rows per room) only about 4 rooms fit.

**Staying under the limit.** Any of these work, and they can be combined:

* Shorten the time range per request and make more requests.
* Narrow the spatial filter (`rooms`, `zones`, `spaces`, `buildings`) so each request covers fewer entities.
* Use a coarser `window.every`.
* [Paginate](/historical-occupancy/reporting-api-overview.md#pagination) the response with `paginate`, keeping `limit` at or below about 20,000 rows per page.

### Interval availability by data age

Data is stored in tiers by age. The tier is selected by the **start** of the queried range:

| Query range starts       | Available intervals   |
| ------------------------ | --------------------- |
| Within the last 3 months | all                   |
| 3–6 months ago           | `15m` `30m` `1h` `1d` |
| More than 6 months ago   | `1h` `1d`             |

### Other changes

* `window.offset` is no longer supported.
* `filter.time_constraints.time_ranges` is evaluated at **minute precision, inclusive on both ends**: `{ "start": "08:00", "stop": "16:59" }` keeps data through 16:59:59. Ranges are applied to the aggregated windows, in `window.timezone` (UTC when it is omitted): a window is returned when any minute of it falls inside a range. With `5m` windows and `08:00`–`16:59`, the first window returned is labelled `08:05` (covering 08:00–08:05) and the last is labelled `17:00` (covering 16:55–17:00); the `17:05` window is not returned. `exclude_days_of_week` drops windows whose start falls on that weekday in the same timezone. Combined with `1d` windows, the range is limited to 1 year.
* `tag_occupancy` requires a `tags` filter and supports only the `max` and `mean` functions; `mean` requires an interval of `15m` or coarser.
* All filters are supported: spatial IDs (`rooms`, `zones`, `spaces`, `buildings`), name-based filters (`room_names`, `building_names`), and sensor/hive filters (`sensors`, `mac_addresses`, `hives`, `hive_serials`). Prefer spatial ID filters where possible — they narrow the query itself, while name and sensor/hive filters are applied to the results.
* Intervals with no data are omitted from the response. To return them, set `"window": { "fill": { "value": 0 } }` — they come back filled with the given value (add `"use_previous": true` to carry the last value forward instead). `create_empty` on its own has no effect. Fill applies once the query returns at least one data point for the series; a range with no data at all returns an empty response. Fill respects `filter.time_constraints`: only windows inside `time_ranges` and on non-excluded weekdays are created, so a `08:00`–`16:59` request with fill returns no overnight rows. Two consequences follow from "at least one data point": a series whose stored rows all fall outside `time_ranges` is not filled, and with the default value filter (`neq: [0]`) a series whose stored values are all zero is not filled either — send `"filter": { "value": { "gte": 0 } }` to keep stored zeros. Filled `1d` rows span one local calendar day in `window.timezone`, so on a DST day they are 23 or 25 hours and their `time` label is the next local midnight, like stored rows.
