OData
Neil Haddley • October 23, 2026
Querying an OData service with $select, $filter, $orderby, $expand and $count, and how OData compares with GraphQL
OData (Open Data Protocol) is an ISO/IEC approved, OASIS standard for building and consuming REST APIs. Microsoft created it, and it is built into products that I use, such as Business Central, Excel's Power Query and Power BI. I used it in my Business Central post without looking closely at it. In this post I look closely, using a public demo service, and then compare it with GraphQL.
The demo service
OData publishes a demo service called TripPin, which holds people, their trips, airlines and airports. Its address is https://services.odata.org/TripPinRESTierService/. It redirects each visitor to a URL that contains a session id, so I can try it without touching anyone else's data.
I sent every request below with curl. I put the URL in single quotes so that the shell leaves the dollar signs alone, and I used the -L option so that curl follows the redirect to the session URL. Without -L, curl prints a short "Object moved" page and nothing else. I removed the @odata.context line from each response to keep it short.
Read the model first
An OData service describes its own data model at the $metadata address.
BASH
1curl -sL 'https://services.odata.org/TripPinRESTierService/$metadata'
The answer is an XML document. It starts like this, and it goes on to list every type and property:
XML
1<EntityType Name="Person"> 2 <Key><PropertyRef Name="UserName" /></Key> 3 <Property Name="UserName" Type="Edm.String" Nullable="false" />
The document also lists the entity sets, which are the collections that I can query. In TripPin they are People, Airlines and Airports.
Ask for only what I need
The $select option picks the properties, and $top limits how many rows come back.
BASH
1curl -sL -H 'Accept: application/json' \ 2 'https://services.odata.org/TripPinRESTierService/People?$top=2&$select=UserName,FirstName'
JSON
1{ 2 "value": [ 3 { "UserName": "angelhuffman", "FirstName": "Angel" }, 4 { "UserName": "clydeguess", "FirstName": "Clyde" } 5 ] 6}
Filter and sort
The $filter option keeps the rows that match, and $orderby sorts them. The operators are words, such as eq for equals.
BASH
1curl -sL -H 'Accept: application/json' \ 2 'https://services.odata.org/TripPinRESTierService/People?$filter=FirstName%20eq%20%27Scott%27&$select=UserName,FirstName,LastName'
JSON
1{ 2 "value": [ 3 { "UserName": "scottketchum", "FirstName": "Scott", "LastName": "Ketchum" } 4 ] 5}
Follow a relationship
A person has trips. The $expand option brings the related items back inside the same response, and a $select inside it limits their properties too.
BASH
1curl -sL -H 'Accept: application/json' \ 2 "https://services.odata.org/TripPinRESTierService/People('russellwhyte')?\$select=FirstName,LastName&\$expand=Trips(\$select=Name,Budget)"
JSON
1{ 2 "FirstName": "Russell", 3 "LastName": "Whyte", 4 "Trips": [ 5 { "Name": "Trip in US", "Budget": 3000 }, 6 { "Name": "Trip in Beijing", "Budget": 2000 }, 7 { "Name": "Honeymoon", "Budget": 2650 } 8 ] 9}
That is one request for a person and their trips, with only the properties that I asked for, so OData also avoids making several calls to collect related data.
Count the rows
There are two ways to count. Adding /$count to the collection returns just the number, and adding $count=true to a query returns the number alongside the rows.
BASH
1curl -sL 'https://services.odata.org/TripPinRESTierService/People/$count'
The answer was 20. With $count=true and $top=1, the service returned one person and an @odata.count of 20.
Under the hood: How a URL becomes a database query
The server reads the query options and applies them to its own data. In an ASP.NET Core service, the OData library applies $filter, $orderby and the other options to an IQueryable, and a provider such as Entity Framework Core turns that into SQL. The service author does not write a method for each filter. The same idea works with other data sources and other languages, because the protocol does not assume a relational database.
When the request is wrong
The service checks every query against the model. When I filtered on a property that does not exist, it replied with a 400 status and an error message.
JSON
1{ 2 "error": { 3 "code": "", 4 "message": "Could not find a property named 'nope' on type 'Trippin.Person'." 5 } 6}
OData and GraphQL side by side
Both let a client ask for less than a whole record, and both can return related data in one call. They differ in where the power sits.
| OData | GraphQL | |
|---|---|---|
| How the client asks | Query options in the URL of a GET request | A query document sent to one endpoint, usually in a POST (GET is also allowed) |
| Filtering | $filter on any property that the model exposes, with no extra server code | Only the arguments that the server defines |
| Related data | $expand | Nested fields |
| Describing the data | The $metadata document | The schema, which a client can also read by introspection |
| Server work | A library applies the query options to the data | A resolver for each field |
| Changes over time | Change tracking is in the OData guidance, but I know of no push channel in the core protocol | Subscriptions |
In my GraphQL post, the server can find a book or an author by id, because I wrote an argument for that. Filtering by name would need another argument and more resolver code. In OData, the same filter is already available on every property. The price is that a client can ask for anything that the model exposes, so the service has to decide what to expose and what to limit.
OData in Business Central
Business Central publishes its pages and queries as OData web services, and my Business Central post used the OData V4 URL in a dataflow. Microsoft's documentation says that the Business Central implementation does not cover every feature in the OData specification, so check its page of known limitations before you rely on a particular query option.