skip to content

Given the OData 4.01 request `GET Customers?$filter=City eq 'Lyon'&$orderby=Name&$top=10&$skip=20&$count=true`, what comes back, and in what order are the options applied?

level: juniorimportance: must knowfreq 34%

answer

  1. order fixed by the specification
  2. filter, then count, then sort
  3. skip always before top
  4. count ignores the paging options

basics

~20 s

Customers 21 to 30 of those in Lyon, sorted by Name, plus the total number of Lyon customers. The service evaluates $filter, then $count, then $orderby, $skip and $top, whatever their order in the URL.

solid answer

~40 s

OData 4.01 says the result MUST be as if the options were evaluated in a fixed order: `$filter` narrows the set to Lyon customers, `$count=true` counts that filtered set, `$orderby=Name` sorts it (ascending when no direction is given), `$skip=20` drops the first twenty and `$top=10` keeps the next ten. The order in the query string does not matter — `$skip` is applied before `$top` even when written after it. The count ignores `$top`, `$skip` and `$expand`, so it reports every matching customer across all pages; in a 4.01 JSON response it appears as `@count` beside the `value` array (`@odata.count` in a 4.0 response). The service may still return fewer than ten rows with a next link if it pages the result itself.

go deeper

for a junior

Recall what each of the five options does and that the URL order is irrelevant: filter, count, sort, skip, then top.

for a middle

Explain why the count ignores $top and $skip, why $skip always precedes $top, and how @count sits beside the value array in a 4.01 JSON response.

for a senior

Bring up the stable-ordering requirement for positional paging, the possibly inexact count, and the fact that server-driven paging can still shorten the page.

for a principal

Weigh what an exact total count costs on a large filtered set, and whether the service should advertise that counting is not supported rather than compute it on every request.

## What each option asks for OData's **system query options** are query-string parameters that shape the collection a URL identifies. The request in the question uses five of them against the `Customers` entity set: | Option | Value here | Effect | |---|---|---| | `$filter` | `City eq 'Lyon'` | keeps only items for which the Boolean expression is true | | `$orderby` | `Name` | sorts the items; ascending unless `desc` is given | | `$skip` | `20` | excludes the first 20 items of the queried collection | | `$top` | `10` | returns at most 10 items | | `$count` | `true` | adds the total number of matching items to the response | String literals sit in single quotes, and `eq` is one of the comparison operators (`eq`, `ne`, `gt`, `ge`, `lt`, `le`), alongside `and`, `or`, `not` and, from 4.01, `in`. ## The evaluation order the specification fixes The OData 4.01 Protocol (section 11.2.1) says the result **MUST be as if** the options were evaluated in this order, regardless of how they are written in the URL: 1. `$schemaversion`, first of all, because it can change everything that follows. 2. Before any server-driven paging: `$apply`, `$compute`, `$search`, `$filter`, `$count`, `$orderby`, `$skip`, `$top`. 3. After server-driven paging: `$expand`, `$select`, `$format`. "As if" matters: a service may run one combined database query, but the answer must match this sequence. The protocol repeats the rule for paging explicitly: where `$top` and `$skip` are used together, `$skip` MUST be applied before `$top`, regardless of their order in the request. ## Walking through the request - `$filter=City eq 'Lyon'` reduces the customers to those in Lyon — say 57 of them. - `$count=true` counts that filtered set: 57. The count includes only results matching `$filter` and `$search`, and it **ignores `$top`, `$skip` and `$expand`**, so it is the total across all pages. - `$orderby=Name` sorts the 57 by name. Nulls sort before non-null values in ascending order, and `false` sorts before `true`. - `$skip=20` drops positions 1–20; `$top=10` keeps positions 21–30. So the client receives ten customers (positions 21 to 30) and the number 57. ## What the response looks like In OData 4.01 JSON, control information is written without the `odata.` infix, so the count is `@count`; a response with `OData-Version: 4.0` writes `@odata.count`. ```json { "@context": "https://api.example.com/odata/$metadata#Customers", "@count": 57, "value": [ { "ID": "C021", "Name": "Arnaud Fils", "City": "Lyon" }, { "ID": "C022", "Name": "Bertin SA", "City": "Lyon" } ] } ``` (The array is shortened; it would hold ten customers.) ## Details that trip candidates - **Stable ordering.** If no unique ordering is imposed through `$orderby`, the service MUST impose a stable ordering across requests that include `$top` or `$skip`. Sorting only by `Name` is not unique, so a careful client adds a key: `$orderby=Name,ID`. - **The count can be approximate.** The protocol warns that the inline count may not exactly equal the items a client can enumerate, because of latency between counting and reading, or inexact calculation on the service. - **Invalid values.** `$count` takes only `true` or `false`; any other value gets `400 Bad Request`. `$count=false`, or no `$count`, hints that the service SHOULD NOT return a count. - **Names and casing.** 4.01 services MUST accept system query option names case-insensitively and with or without the `$` prefix; clients that must also work with 4.0 services use lower-case names with `$`. - **No repeats.** The same system query option MUST NOT appear more than once for a resource. - **Server-driven paging still applies.** Paging sits between `$top` and `$expand` in the order, so a service may split even a ten-item result and return a next link. ## The $filter expression language in brief `$filter` takes a full Boolean expression, not just equality, which is why OData URLs can replace many bespoke endpoints: - **Comparison and logical operators:** `Amount ge 100 and (City eq 'Lyon' or City eq 'Nice')`, `not endswith(Email,'.test')`. - **Membership (4.01):** `City in ('Lyon','Nice')` is true if the left operand is a member of the right-hand list. - **String functions:** `contains(Name,'bio')`, `startswith(Name,'Ar')`, `endswith`, `tolower`, `toupper`, `trim`, `length`, `indexof`, `substring`, and from 4.01 `matchesPattern`. - **Date and time functions:** `year(CreatedAt) eq 2026`, `month`, `day`, `date`, `now()`. - **Arithmetic:** `add`, `sub`, `mul`, `div`, `mod`, and from 4.01 `divby`. Items for which the expression is false or null are left out of the response. ## Where the boundary sits This question is about OData's own grammar and evaluation rules. How you would design a sort or filter parameter for a plain REST API, or whether to choose offset or cursor paging there, are separate design questions; OData simply fixes the semantics so that every client and service agree on what one URL means.

  • Why does OData 4.01 require a stable ordering whenever $top or $skip is used without a unique $orderby?
    Positional paging only works if positions do not move between requests. If two customers share a `Name`, they could swap places between the request for positions 11–20 and the one for 21–30, so a customer could appear twice or never. The protocol therefore says the service MUST impose a stable ordering across requests that include `$top` or `$skip`; a client still does well to add a unique key, as in `$orderby=Name,ID`.
  • Why might the OData count returned with $count=true differ from the number of items a client finally pages through?
    The protocol allows it: the count is computed once, and items can be added or removed before the client reaches the last page, or the service may calculate the count inexactly on a large set. A client should treat the count as a display figure, such as a page total, and rely on the absence of a next link, not on the count, to know it has reached the end.

saying these in an interview costs you the question

  • Options are applied left to right in the order they appear in the URL
  • $count=true returns the number of items on the current page
  • $top is applied before $skip when it is written first
  • Without $orderby, rows may come back in any order from page to page
  • $count=true replaces the entities with a bare number