Query Size and Performance
Agility applies no default page size. A request like:
GET ~/rest-1.v1/Data/Story
does not return a reasonable first page. It attempts to return every Story the calling identity can see, with a default set of attributes on each one. On a mature instance that is tens of thousands of assets in a single response.
This is the most common cause of self-inflicted performance problems in Agility integrations, and it is easy to hit because nothing about the request looks wrong. It succeeds. It just succeeds enormously.
Three Defaults That Compound
Each of these is individually reasonable. Together they mean an unbounded request returns far more than most callers expect.
| # | Default | What it means |
|---|---|---|
| 1 | No page limit | Omitting page returns the entire result set. The response reports pageSize="2147483647" — Int32.MaxValue, the server's way of saying "unpaged" |
| 2 | A default attribute selection | Omitting sel does not return a minimal asset. It returns GetBasicAttributeDefinitions(), a per-type basic set — real fields, on every asset returned |
| 3 | Implicit relation expansion | Every relation you select additionally returns the related asset's Name, ShortName and DisplayBy. So sel=Status returns Status, Status.Name and often Status.ShortName |
The multiplication is what hurts: (all matching assets) × (default attribute set) × (silent
expansion per relation). Adding one more relation to sel does not add one column, it adds up
to three — across every row in an unbounded result set.
All three are documented in detail on the query grammar overview and Sort and paging.
What to Send Instead
Always be explicit about how many assets and which attributes.
rest-1.v1 — Positional Paging, Size First
GET ~/rest-1.v1/Data/Story?sel=Name,Number,Status.Name&page=50,0&needTotal=true
page=<size>,<start> — size comes first. This is the opposite of most APIs, and getting it
backwards fails silently: page=0,50 is a zero-size page, not "the first 50".
Advance start by size per page, not by one:
GET ~/rest-1.v1/Data/Story?sel=Name,Number&page=50,0 # assets 1-50
GET ~/rest-1.v1/Data/Story?sel=Name,Number&page=50,50 # assets 51-100
query.v1 — a Named Mapping
{
"from": "Story",
"select": ["Name", "Number", "Status.Name"],
"where": { "Scope.Name": "CallCenter" },
"page": { "size": 50, "start": 0 },
"needTotal": true
}
query.v1 uses a named mapping — page: {size, start}. rest-1.v1 uses a positional
query string — page=<size>,<start>. A query ported between the two cannot carry the paging
across unchanged.
Paging Correctly
Sort before you page. Paging is applied after sorting. Paging an unsorted list has no stable order underneath it, so assets can repeat or be skipped between pages even though every individual response looks fine.
GET ~/rest-1.v1/Data/Story?sel=Name&sort=+Number&page=50,0
Stop on a short page — you don't need a total. The cheapest correct pager never asks how many results exist: request pages until one comes back with fewer results than the page size — that page is the last. (A result set that is an exact multiple of the page size costs one extra empty-page request, which is fine.)
start = 0
loop:
results = query(page: {size: N, start})
process(results)
if len(results) < N: done
start += N
This also skips the cost of computing the total — a real query of its own on every page.
Reach for needTotal=true only when you genuinely need the count (a progress bar, a
report header). Two traps when you do:
totalis only computed on a paged query when requested; otherwise the paged response omits it — "not computed" looks like a missing attribute, not a count of zero.- Whether it is computed by default comes from the per-instance
ApiDefaultNeedTotalsetting, so an integration that pages offtotalcan work against one instance and break against another with no code change. Always sendneedTotal=truewhen you rely on the count, and treat a missingtotalas "unknown" rather than as a number.
Finding Which Projection Is Expensive
"Select less" is easy advice and hard to act on when a query has twenty entries
in select and you do not know which one is costing you. A leave-one-out
measurement answers that directly, and needs nothing but a clock.
The method:
- Time the full query several times and average it. This is your baseline.
- For each entry in
select, remove only that entry and time the query again. - The difference between the baseline and each reduced run is that projection's marginal cost.
const baseCost = await runTrials(query, trials);
for (const projection of query.select) {
const reduced = {
...query,
select: query.select.filter((p) => p !== projection),
};
const costWithout = await runTrials(reduced, trials);
console.log(projection, 'costs', baseCost - costWithout, 'seconds');
}
Two details matter for the numbers to mean anything:
- Discard a warm-up run before timing. The first execution pays for plan compilation and a cold cache, and will dominate a small sample.
- Average several trials. Individual runs on a shared instance vary enough to invert the ranking of two similar projections.
This measures marginal cost, not total. Two projections that traverse the same relation each look cheap on their own, because removing one still leaves the join in place for the other. If nothing looks expensive but the query is slow, remove a whole group of related paths at once rather than one at a time.
What Tends to Be Expensive
The projections worth measuring first, from the shape of the syntax rather than from any benchmark:
| Pattern | Why it costs |
|---|---|
Rel.Sub.Attribute | Each dot is another join |
Rel[filter].Attribute | An inline filter is evaluated per traversed asset |
Rel.Attribute.@Sum | An aggregation rolls up a whole set per row |
SubsMeAndDown:Type[...] | A recursive relation, downcast, then filtered |
History[...] | History multiplies rows by version count |
| Any relation at all | Silently also returns Name, ShortName and DisplayBy |
That last row is the one people miss — see the three compounding defaults above.
Select Only What You Need
Naming attributes explicitly is the other half of the fix, and often the larger one — it reduces the width of every row rather than the number of rows.
- Name every attribute you actually use. The default set is a convenience for exploration, not a payload you want in production.
- Watch relation count. Each relation in
selcosts up to three returned attributes, not one. - Custom fields are never in the default set — they must be selected explicitly regardless.
Discover what is available for a type from meta.v1 rather than guessing:
GET ~/meta.v1/Story?xsl=api.xsl
See Meta API and Attribute paths.
Single-Asset Requests Ignore page
On a single-asset Data URL such as /Data/Story/1234, the where, sort, page and find
parameters are accepted and silently dropped. There is nothing to page, so this is harmless
here — but it is the same silent-drop behaviour that causes trouble elsewhere, and it does not
apply to /Hist/... URLs, where all four take effect.
See the query grammar overview.
Bulk Operations Have No Implicit Limit Either
The same principle applies to ~/api/asset. A query-scoped update with no where matches
every asset of that type the caller can see:
{ "from": "Story", "update": { "Description": "..." } }
Before running a query-scoped update or operation, use preview mode to see exactly which assets it will touch. See Updating assets.
Extracting at Scale: Full Loads and Delta Sync
Feeding a warehouse or data hub from Agility is a common integration, and the
same field-proven rules apply whichever surface you use — query.v1,
api/asset or rest-1.v1 all page, all emit JSON, and all can include or
exclude deleted assets. The endpoint choice matters far less than how you
query it:
- Project the absolute minimum. Every attribute you select costs something, every relation costs a join — and you should know what each projected attribute means before you ship it downstream.
- Filter the result set to the absolute minimum — scope, team, release. Do not extract the instance and filter in the warehouse.
- Filter on OIDs, not names. Names are localized, editable and duplicable; OIDs are stable.
- Page without
needTotal. Computing the total costs an extra query on every page and you do not need it: keep requesting pages until one comes back with fewer results than the page size — that one is the last. - Prefer fewer, larger pages. Per-request overhead dominates at small page sizes; page sizes in the hundreds to low thousands have worked well in production extractions. Very large pages combined with wide projections can time out instead — if a request hangs, shrink the page or the projection, not both directions at once.
- Use the UTC date attributes — filter and project
ChangeDateUTC/CreateDateUTC, not their local-time counterparts, to stay immune to timezone and DST weirdness. - Pin one upper time bound for the whole run. Data changes while you
page. Filtering every query in the run with the same fixed
ChangeDateUTC < run-startbound keeps the extract consistent instead of torn across pages.
Delta sync follows directly: an incremental load is just "everything
changed since the previous run". Record each run's fixed upper bound, and
next time filter ChangeDateUTC from that value forward:
{
"from": "Story",
"filter": ["ChangeDateUTC>='2026-06-01T00:00:00'", "ChangeDateUTC<'2026-07-01T00:00:00'"],
"select": ["Number", "Name", "ChangeDateUTC"],
"page": { "size": 500, "start": 0 }
}
Two operational notes:
- On-premise instances may need offset-fetch paging and (when projecting many custom attributes or relations) query-optimization settings enabled in the instance configuration — that is an administrator task; raise it with Digital.ai Support.
- For continuous heavy analytics, ask whether the Agility Analytics data mart fits better than repeated API extraction — extraction re-derives what the data mart already maintains.
Two clarifications that save confusion:
- "Bulk API" naming. Paginated
query.v1extraction is sometimes informally called "the bulk API". On this site, Bulk API always meansapi/asset— the surface whose requests batch queries, creates, updates and operations. - Extracting several types per round trip. An
api/assetrequest can carry multiple query commands, so one call can fetch a page of Epics, Stories and Defects together. Each query still pages independently — batching reduces round trips, not result sizes. One caution: the server's SQL timeout applies per query, not per request, so a request stuffed with several large queries can legally run far longer than any single query could. Keep batched queries modest, and set your client's timeout with the whole request in mind.
A query that takes long enough to brush against the SQL timeout is placing heavy load on the same database that serves the interactive product — concurrent Agility users feel your extraction as slow boards and grids. Treat long-running queries as a signal to narrow the projection, tighten the filter or shrink the page, not as a budget to spend.
Checklist
Before shipping an integration:
- Every list query sends an explicit
page - Every list query sends an explicit
sel/select - Every paged query sends a
sort, so paging is stable -
needTotal=trueis sent wherevertotalis read, and its absence is handled - Relation selections are counted with expansion in mind
- Query-scoped bulk updates were checked in preview mode first
Related
- Sort and paging — the normative reference
- Query grammar overview — selection defaults and expansion
- Performance — caching and broader optimisation
- Choosing an API