The Dataverse Web API has two query dialects. OData is the REST-friendly one that most tutorials use first: ?$filter=...&$select=.... FetchXML is the XML-based one inherited from the pre-REST Dynamics era, invoked via the Web API as ?fetchXml=. They overlap for simple cases, and they diverge sharply when the query gets complex.
After three years of integrations, here is our split: OData by default, FetchXML when OData cannot express the query or when performance requires it.
The simple cases are the same
A read of accounts created this year, ordered by revenue:
OData:
FetchXML:
Both work, both return the same result, both hit the same underlying indexes. For cases this simple, OData is shorter and we use it.
Where OData starts to hurt
Aggregate queries. OData supports aggregation via the $apply parameter, but support in Dataverse is partial. Some aggregations that should work fall back to client-side computation because the server refuses the query. FetchXML's aggregate support is mature and battle-tested: always works.
Complex link joins. OData can traverse single-valued navigation properties inline ($expand=primarycontactid($select=fullname)), but cross-entity filters via OData get awkward. "Accounts whose primary contact is in Texas" is clean in FetchXML via a with filter; in OData it requires nested filter syntax that older Dataverse builds sometimes choke on.
Aliases for computed fields. FetchXML lets you alias aggregate results (). OData's $apply=aggregate(...) returns the result under an auto-generated key. If you need a specific alias for downstream consumers, FetchXML is clearer.
Where FetchXML hurts
URL length and encoding. A FetchXML query passed via ?fetchXml=... in a GET URL has to be URL-encoded, which balloons the length. Long queries hit proxy and gateway URL-length limits (2048 or 4096 bytes depending on intermediaries). OData's flat query string stays shorter.
Readability in code review. A 10-line OData filter is scannable. A 50-line FetchXML document in a JavaScript string is not. When the query lives in source code and will be maintained, OData's brevity matters.
Toolchain support. Modern tooling (Dataverse SDK for JavaScript, pac CLI, typed client libraries) defaults to OData. FetchXML integration exists but is second-class.
The split we use
Default to OData. Reach for FetchXML when any of:
- The query involves aggregates (sum, count, avg, min, max with group-by)
- The query needs complex cross-entity joins with filtering on the joined table
- You are reusing a saved view (views are stored as FetchXML; executing them natively is FetchXML)
- Existing team knowledge is FetchXML-heavy and the team does not want to re-tool
Pagination
OData supports @odata.nextLink paging with a page size via Prefer: odata.maxpagesize=500. The server returns up to 500 rows plus a @odata.nextLink URL you call for the next batch. This scales to any number of rows without loading them all into memory.
FetchXML paging uses page and count attributes with a paging cookie returned in the response. Functionally similar, syntactically more awkward to chain in code.
For integrations that stream millions of rows, we use OData paging:
The server handles cursor state. We never load more than 1000 rows into memory at a time.
The 30-second timeout
Dataverse Web API requests time out at 30 seconds server-side (some long-running operations have different limits). Queries that scan large tables without an index hit the timeout first. Symptoms: the request comes back after ~30 seconds with a platform error; retries fail the same way.
The culprit is almost always one of:
- Filter on a non-indexed column. Custom string columns are not indexed by default. $filter=acme_notes contains 'urgent' scans every row in the table.
- Sort on a non-indexed column. Same issue; the server needs to sort the full result set.
- Aggregate over a large unfiltered range. "Sum revenue across all accounts" on a 500,000-row table forces a full scan.
Fixes:
- Add an index on the filtered/sorted column. Dataverse indexes primary key, foreign keys, and alternate key columns by default. Everything else, you add manually via the maker portal (Settings → Performance).
- Narrow the filter to a small subset before the expensive operation. $filter=createdon ge 2026-01-01 and acme_notes contains 'urgent' with an index on createdon returns in milliseconds.
- For aggregates, pre-compute via a rollup column or a scheduled aggregation job; query the precomputed value instead.
The query review we do monthly
Every month, we pull the slowest-10 Dataverse queries for each client environment from the admin analytics and review:
- Is there an index we should add?
- Is the filter correct, or is it scanning unnecessary rows?
- Is the aggregate something we can precompute?
Most months, one query deserves an index tweak. Occasionally the fix is algorithmic ("don't run this query on every form load; load on demand"). The review catches performance regressions before they become user-visible complaints.
The rule we give new developers
If your query is "find rows by simple conditions and return a few columns," use OData. If your query has a SUM or a cross-entity filter, use FetchXML. If you find yourself writing OData with three levels of nested $expand, consider whether FetchXML's link-entity would read better.
And if your query is slow, the fix is almost never to switch between OData and FetchXML - the fix is almost always to add an index or narrow the filter.