The complete query grammar: arguments, operators, grains, aggregations, paging and errors.
On this page
The two operations
reporting_datasets — the catalog
Metadata only, no arguments. Returns every dataset with its dimensions and metrics. This is the
authoritative list: names you can pass to reporting_data come from here, and the
description fields carry each dataset's caveats.
{
reporting_datasets {
name
description
required_integrations
last_refresh_at
dimensions { id name type grains }
metrics { name description unit aggregations default_aggregation supported_dimensions }
}
}
| Field | Meaning |
|---|---|
name | Pass this as dataset. |
description | What it measures, and the caveats that apply to it. Read this. |
required_integrations | Which upstream connection feeds it. If a property is not connected to that source, it has no rows here. |
last_refresh_at | ISO timestamp of the last successful build. Useful as a freshness gate before a sync. |
dimensions[].type | tenancy, time or category — the type determines which filter operators are legal. |
dimensions[].grains | Non-null on time dimensions only: the buckets the dimension defines. A pre-bucketed dataset still lists them today even though it rejects them — its description says so, and that is the authority until the catalog is corrected. |
metrics[].aggregations | Every aggregation this metric allows. Anything else is rejected. |
reporting_data — the rows
Runs one query and returns aggregated rows. Pass the spec as GraphQL variables —
filters, group_by, order_by and window are JSON
values, so variables are cleaner than inlining them in the document.
query Pull($group: [JSON], $filters: [JSON], $order: [JSON]) {
reporting_data(
dataset: "ga4_session_source_daily"
metrics: ["Sessions", "New Users"]
group_by: $group
filters: $filters
order_by: $order
page_size: 5000
) {
dataset
rowCount
totalRows
bytesProcessed
cacheHit
cursor
rows
}
}
{
"group": ["property", "channel", {"dim": "date", "grain": "month"}],
"filters": [
{"dim": "date", "op": ">=", "value": "2026-01-01"},
{"dim": "channel", "op": "!=", "value": "Unassigned"}
],
"order": [{"by": "Sessions", "dir": "desc"}]
}
One query per request. Each reporting_data field runs a warehouse job,
so only one is allowed per GraphQL request. Two in one document returns an error rather than running
both.
Arguments
| Argument | Type | Description |
|---|---|---|
dataset | String | A name from reporting_datasets. Required, except when continuing with a cursor. |
metrics | [String!] | One or more metric names from that dataset. Required (at least one), except when continuing with a cursor. An entry may also be {"name": "Sessions", "agg": "avg"} to pick a non-default aggregation. |
group_by | [JSON] | Dimension ids to group by. An entry is an id ("date") or a bucketed time dimension ({"dim":"date","grain":"month"}). Max 6, no duplicates. Omit entirely for a single total row. |
filters | [JSON] | {"dim":…,"op":…,"value":…} objects, AND-ed together. Values are bound as query parameters, never interpolated. |
order_by | [JSON] | {"by":…,"dir":"asc"|"desc"} over a grouped dimension or a selected metric. |
window | JSON | Per-partition top-N: {"partition_by":[…],"order_by":[…],"top_n":N}. |
limit | Int | Top-N mode: one response of at most this many rows. Mutually exclusive with page_size. |
page_size | Int | Paginated mode: rows per page, max 10,000. The default when you set neither this nor limit. |
cursor | String | A cursor from a previous page. It identifies the query on its own — send it with page_size and nothing else. |
Metrics and aggregations
A metric is requested by its display name. Each has a default_aggregation that applies
when you do not ask for one, and an aggregations list of everything it allows.
| Aggregation | Applies to | Column name when not the default |
|---|---|---|
sum | Additive measures (spend, clicks, sessions). Also the default on the daily GA4 user counts, which are not additive — see below | Total … |
avg | Measures and rates | Average … |
min / max | Measures | Minimum … / Maximum … |
count | Row counts | Count of … |
ratio | Ratio metrics only (CTR, CPC, engagement rate) | — only aggregation allowed |
The output column is renamed when you pick a non-default aggregation. Requesting
{"name":"Sessions","agg":"avg"} returns a column called Average Sessions, not
Sessions. Only the default aggregation keeps the plain name. Map columns by the name you
expect, and a schema change will fail loudly instead of silently producing nulls.
Not every metric allows every aggregation — aggregations in the catalog is the
list, and anything outside it is rejected. Several metrics forbid sum outright and default
to avg or max (the property-reference datasets, reputation scores, Woorank,
and Search Console Position). count_distinct is not currently permitted by any
metric in the catalog.
The default is not always the safe choice. Total Users and
Active Users on the daily GA4 datasets default to sum, and summing distinct
people is exactly the over-count described in the overview. Omitting agg is what produces
it. Use the ga4_users_* datasets when you want a user count.
Ratio metrics (Paid CTR, Cost per Click,
Engagement Rate…) accept only ratio, and are always computed as a
ratio of sums — total clicks ÷ total impressions over the grouped rows — never an
average of per-row ratios. That is the arithmetically correct rollup provided both the numerator and
the denominator are additive at the row grain, which is the usual case.
Two places where that proviso fails. On paid_ad_copy_daily, rows fan
out across keyword × headline × description, so each ad's cost and clicks repeat a
different number of times and every rolled-up ratio is re-weighted by that fan-out — the ratios
are trustworthy only for a single ad on a single day. And Events Per User (GA4) divides
by a distinct-user count, which is not additive, so the denominator inflates as you roll up and the
ratio comes out low. Take cost-per-acquisition and conversion-rate figures from
paid_campaigns_daily or paid_ads_grouped_daily.
Dimensions and grains
Three dimension types, with different rules:
| Type | Dimensions | Behaviour |
|---|---|---|
tenancy |
agency, company, property |
Present on every dataset. Values are ids, returned as strings. A fact can map to more than one entity, so grouping by a tenancy dimension expands such a row into one row per entity — which means a property-grouped total is slightly higher than the same query ungrouped, and rows whose scope is empty drop out entirely. Take portfolio totals from the ungrouped query. |
time |
date |
Accepts a grain. Values bind as dates when written YYYY-MM-DD. |
category |
Everything else — channel, campaign, keyword, city, device… |
String-valued. The only type the text operators accept. |
Grains
A time dimension can be bucketed coarser than it is stored:
"group_by": [{"dim": "date", "grain": "month"}]
| Grain | Bucket |
|---|---|
day | The stored day (the default when no grain is given). |
week | ISO week, Monday-first. Returned as the week's Monday. |
month | Calendar month, returned as the 1st. |
quarter | Calendar quarter, returned as its first day. |
year | Calendar year, returned as Jan 1. |
A grain on a non-time dimension is an error. So is a grain on the pre-bucketed
ga4_users_* datasets — their rows already are the bucket.
Pre-bucketed datasets
ga4_users_daily, ga4_users_weekly and ga4_users_monthly
require group_by on date. Their measure is a distinct-user
count GA4 computed for the whole bucket, and adding two buckets counts a returning visitor twice
— no aggregate can undo that. A query that would collapse the buckets is rejected rather than
silently answered wrong.
Filters and operators
Each filter is {"dim": …, "op": …, "value": …}. Filters are AND-ed. Omit
op and it defaults to =.
| Operator | Value | Notes |
|---|---|---|
= != | scalar | Exact match. |
> >= < <= | scalar | Ranges. Mostly used on date. |
IN | non-empty array | Any of. An empty array is an error, not "match everything". |
STARTS_WITH | string or array | Case-sensitive prefix. Category dimensions only. |
CONTAINS | string or array | Case-sensitive substring. Category dimensions only. |
NOT_CONTAINS | string or array | Contains none of the values. Category dimensions only. |
CONTAINS_IC | string or array | Case-insensitive substring — for human search boxes. |
Text operator rules
- Category dimensions only. A text operator on
dateor on a tenancy dimension is rejected. - A list means "any of", OR-ed within that one filter. Top-level filters stay AND-ed,
so
{"op":"STARTS_WITH","value":["brand","competitor"]}is a single "starts with either" condition. - Values are literal.
%and_match themselves; there are no wildcards. Empty strings are rejected, because an empty needle silently matches everything. NOT_CONTAINSkeeps null values. A row whose dimension is null does not contain your needle, so it survives the exclusion. That makes anCONTAINS/NOT_CONTAINSpair an exact partition of the rows.
Filtering on tenancy dimensions
Because a fact row can map to several entities, a tenancy filter matches a row if any of its
entities match. The ids below are placeholders you supply — the
companies query returns the ones your token can see:
[{"dim": "property", "op": "IN",
"value": ["YOUR_PROPERTY_ID", "ANOTHER_PROPERTY_ID"]},
{"dim": "company", "op": "=", "value": "YOUR_COMPANY_ID"}]
Ids are 16-character values and should be sent, stored and compared as strings —
they are larger than a double represents exactly, so treating one as a number loses precision. Get them
from the companies query. A tenancy filter narrows within your grants; it can never widen
them.
Filtering on dates
Write dates as YYYY-MM-DD and they bind as real dates. A half-open range is the safest
form — >= the first day, < the day after the last:
[{"dim":"date","op":">=","value":"2026-01-01"},
{"dim":"date","op":"<", "value":"2026-07-01"}]
Ordering
order_by takes {"by": …, "dir": "asc"|"desc"}. dir defaults to
desc. by must name a dimension you grouped by, or a metric you selected
— using the metric's output name, so Average Sessions if you asked for
avg. Anything else is an error.
"order_by": [{"by": "Paid Spend", "dir": "desc"}, {"by": "property", "dir": "asc"}]
Per-partition top-N
window ranks rows within each bucket — the top 3 cities for every property,
the top 10 keywords per campaign — in one query.
"group_by": ["property", "city"],
"window": {
"partition_by": ["property"],
"order_by": [{"by": "Sessions", "dir": "desc"}],
"top_n": 3
}
partition_by | The buckets to rank within. Each must be a grouped dimension, so window requires group_by. No duplicates. |
|---|---|
order_by | What to rank by — same shape as the top-level order_by, over a grouped dimension or selected metric. |
top_n | Positive integer cutoff. |
Ties share a rank, so a partition can return more than top_n rows when values tie at the
cutoff.
Top-N mode vs paginated mode
Top-N mode (limit) | Paginated mode (page_size) | |
|---|---|---|
| Use for | Dashboard tiles, "top 20 campaigns" | Any complete pull into a warehouse |
| Responses | One | One per page, followed by cursor |
totalRows | Not reported — you cannot tell whether more matched | Exact match count |
| Pair with | order_by, always — otherwise "which 20" is arbitrary | — |
The two are mutually exclusive; sending both is an error. Sending neither gives you paginated mode with a page size of 10,000, which is what a bulk pull wants.
limit truncates silently. If 40,000 rows match and you asked for 1,000,
you get 1,000 rows and no indication that the rest exist. Use it only where truncation is the point.
Paginated mode
The first call runs the query once and returns page 1 plus a cursor. Each follow-up call
reads the next page of that same finished result — the query does not re-run, so later pages come
back quickly and cannot shift under you when the nightly build replaces a table mid-pull.
A continuation request carries only cursor and optionally
page_size. The cursor already identifies the dataset, metrics and filters; sending a spec
alongside it is an error rather than a silently ignored change.
query Page($cursor: String) {
reporting_data(cursor: $cursor, page_size: 5000) {
rowCount totalRows cursor rows
}
}
Loop while cursor is non-null. It is null on the last page.
- Cursors expire after 23 hours. An expired cursor returns a clear error; the remedy is to re-run the query from the start.
- Cursors are bound to the user who created them. Presenting one with a different account's token is refused.
- Treat a cursor as opaque. Its format is not a contract.
Response fields
| Field | Type | Meaning |
|---|---|---|
dataset | String | Echo of the dataset queried. |
rows | [JSON] | The rows. Keys are exactly the dimension ids you grouped by and the output names of the metrics you selected. |
rowCount | Int | Rows in this response. |
totalRows | Float | Total rows the query matched. Paginated mode only. |
bytesProcessed | Float | How much data the query read. A rough proxy for how heavy it was. |
cacheHit | Boolean | Whether the warehouse served the query from cache. |
cursor | String | Next page, or null on the last page. |
bytesProcessed is per job, not per page. Every page of a paginated pull
echoes the same total, so summing it across pages multiplies it by the page count. Page 1's value
describes the whole pull.
Errors
Errors come back in GraphQL's errors array with a code in extensions.code.
The message is specific — it names the dataset, dimension or metric at fault — so log it.
| Code | Means | Do |
|---|---|---|
BAD_USER_INPUT |
Unknown dataset, dimension or metric; an unsupported operator or aggregation; a malformed spec; an expired cursor; a request body over the size limit. | Fix the request. Do not retry unchanged. |
UNAUTHENTICATED |
Missing, malformed or revoked token. | Check the Authorization header; mint a new token if it was revoked. |
FORBIDDEN |
The token is valid but the account has no property grants, or a cursor belongs to a different user. | Contact your Repli account contact — this is an access-grant issue. |
INTERNAL_SERVER_ERROR |
Something failed on our side. | Retry with backoff. If it persists, get in touch with the time and the query. |
An expired cursor also carries extensions.reportingStatus: 410, which lets a sync job
distinguish "start this pull again" from "this request was malformed" without parsing the message.