Microsoft Graph $filter, $select & $expand: OData Cheat Sheet (2026)
The OData query parameters every Microsoft Graph developer needs in 2026 — $filter, $select, $expand, $orderby, $count, $search and the gotchas that bite in production.

Why OData Query Parameters Decide Whether Your Graph Code Scales
Microsoft Graph speaks OData. Every list endpoint accepts the same family of query parameters — $filter, $select, $expand, $orderby, $top, $skip, $skiptoken, $count, $search, $format — and how well you wield them is the single biggest factor in whether your integration runs in 200 ms or trips throttling at 1,000 users. Most performance bugs in Graph code are not auth problems or retry-policy problems; they are query problems. Someone fetched all of /users, pulled it back to memory, and then filtered in JavaScript.
This cheat sheet collects the OData patterns I reach for every week building production M365 integrations: which combinations are supported on which resources, the difference between $search and $filter, when you must pair ConsistencyLevel: eventual with $count=true, and the small syntax details (single quotes, lambda operators, cast) that decide whether your call returns 200 or 400. Examples are written against https://graph.microsoft.com/v1.0/; everything works the same on /beta unless noted.
---
$select — Always, On Every Call
$select is the cheapest performance win in Graph. By default, every entity endpoint returns its full default property set, which is often 30+ fields including extension dictionaries you do not need. $select shrinks the response to exactly the columns you care about, which speeds up the wire transfer, reduces JSON parsing time, and — for some resources — unlocks faster server-side paths.
GET /v1.0/users?$select=id,displayName,mail,userPrincipalNameFive rules:
- Always include
idin$select. Without it you cannot follow up with PATCH, DELETE, or relationship lookups.
- The response's
@odata.contextwill reflect that you asked for a projection:...$metadata#users(id,displayName,mail,userPrincipalName). Trust this — if a property you asked for is missing, look at the context first.
$selectdoes not help with auth. You still need the broad permission required by the resource (User.Read.Allfor/users), even if you only ask fordisplayName.
- For navigation properties,
$selectand$expandcompose:$expand=manager($select=id,displayName). More on that below.
$selectcannot retrieve properties that require explicit opt-in. For example,signInActivityon/usersrequiresAuditLog.Read.Alland Microsoft Entra ID P1; asking for it without the licence returns 400, not an empty value.
If you are building a list view, fetch only the columns you render. If you are building a detail view, fetch the detail set in a separate call. Resist the temptation to "select everything just in case" — Graph charges you for what you ship, in latency and in throttling cost.
---
$filter — The Syntax That Trips Everyone Up
$filter is OData's WHERE clause. The grammar looks simple until you hit a string with an apostrophe in it, a date that isn't quoted, or a property that quietly requires advanced query parameters.
The basic operators are the ones you would expect:
GET /v1.0/users?$filter=accountEnabled eq true
GET /v1.0/users?$filter=startsWith(displayName, 'Adele')
GET /v1.0/users?$filter=mail ne null
GET /v1.0/users?$filter=createdDateTime ge 2026-01-01T00:00:00Z
GET /v1.0/groups?$filter=groupTypes/any(c:c eq 'Unified')Five things to internalise:
- Strings are single-quoted. Double quotes return 400. Apostrophes inside strings are escaped by doubling them:
'O''Brien'.
- Dates are unquoted ISO 8601.
createdDateTime ge 2026-01-01T00:00:00Z— no quotes, always include the timezone (Zor offset), and use full date-time, not just2026-01-01.
nullchecks useeq null/ne null— notis null.mail eq nullmatches users without a mail address.
startsWith,endsWith,containsare functions, not operators. Argument order is(property, value).endsWithandcontainson/usersand/groupsrequire advanced query parameters (ConsistencyLevel: eventualplus$count=true).
- Collections use lambda operators —
anyandall.assignedLicenses/any(l:l/skuId eq guid'...')matches users with at least one matching licence.allis rarely supported; assumeanyunless docs say otherwise.
A common bug: combining filters with and/or while forgetting precedence. Graph requires explicit parentheses for mixed boolean groups:
GET /v1.0/users?$filter=(accountEnabled eq true) and (startsWith(displayName,'A') or startsWith(displayName,'B'))Without the parens, Graph either errors or — worse — interprets and as binding tighter than or in ways that silently change your result set. Always parenthesise.
---
Advanced Query Parameters — The ConsistencyLevel: eventual Header
Some $filter and $orderby operations on directory objects (/users, /groups, /applications, /servicePrincipals, /devices) require Graph's advanced query parameters mode. You opt in with two things on the same request:
- The header
ConsistencyLevel: eventual
- The query parameter
$count=true
Without both, the request returns a 400 with Request_UnsupportedQuery. With both, you unlock:
endsWith,not,neon string properties
$searchon/users,/groups,/applications
$orderbycombined with$filteron the same property
$countreturning the total in@odata.count
GET /v1.0/users?$filter=endsWith(mail,'@contoso.com')&$count=true
ConsistencyLevel: eventualIn the official SDKs, you set the header per-request:
import { Client } from "@microsoft/microsoft-graph-client";const users = await client
.api("/users")
.header("ConsistencyLevel", "eventual")
.count(true)
.filter("endsWith(mail, '@contoso.com')")
.select("id,displayName,mail")
.get();
console.log("Total matching:", users["@odata.count"]);
Rule of thumb: if you are filtering directory objects and the docs do not explicitly say it works without advanced query, set ConsistencyLevel: eventual and $count=true. The cost is negligible and it eliminates a whole class of "works in dev, 400s in prod when the data shape changes" bugs.
---
$expand — Pulling Related Entities In One Call
$expand follows navigation properties so you do not have to make a second round trip. The classic case is fetching a user along with their manager:
GET /v1.0/users/adele@contoso.com?$expand=managerYou can nest $select inside $expand to project the related entity:
GET /v1.0/users?$expand=manager($select=id,displayName)&$select=id,displayName,managerThree rules:
- Each resource has a hard cap on how many entities you can expand. On
/usersit is 20 manager entries; on/groups/{id}/membersit is 20 too. Beyond that, page or call separately.
$expandis not transitive.$expand=managerreturns the direct manager, not the manager's manager. To go further, follow up:GET /users/{id}/manager?$expand=manager.
- Some collections can only be expanded with
$selectfor the parent. For example, groupmembersexpansion on a list of groups needs$select=id,displayName,members; otherwise Graph either ignores the expansion or returns 400.
For SharePoint sites, $expand=lists works but is rate-limited — fetch lists separately if you need more than the few that fit in one response.
---
$orderby, $top, and Paging With $skiptoken
Graph paginates lazily. You ask for $top=N, you get up to N rows, plus an @odata.nextLink you call to get the next page. Do not parse the nextLink — pass it back as-is:
async function getAllUsers(client: Client) {
const all: any[] = [];
let response = await client
.api("/users")
.select("id,displayName,mail")
.top(100)
.get(); while (response) {
all.push(...response.value);
if (!response["@odata.nextLink"]) break;
response = await client.api(response["@odata.nextLink"]).get();
}
return all;
}
Things to know:
- Default page size varies by resource.
/usersreturns 100./messagesreturns 10. Always pass an explicit$topso you do not get surprised in the next quarter when defaults change.
- Maximum
$topis 999 for most resources. Beyond that, page.
$skipis not supported on most directory endpoints. Use$skiptoken(which Graph provides vianextLink) instead.$skipworks on Outlook resources like/me/messages.
$orderbymust reference the same property as your$filterwhen filtering on directory objects, unless you opt into advanced query parameters.$orderby=displayNameafter$filter=startsWith(displayName,'A')is fine;$orderby=createdDateTimewith that filter is not, withoutConsistencyLevel: eventual.
---
$count, $search, and the Difference Between Them
$count and $search look similar and do completely different things.
$count=true adds a @odata.count property to the response with the total number of matching entities. It is a counter, not a filter. Useful for pagination UIs.
$search performs a relevance-ranked text search across a configured set of properties. It does not use OData equality semantics — it tokenises, stems, and ranks. Search is supported on a narrow set of resources (/users, /groups, /applications, /messages, /sites, /drives/{id}/root) and the syntax is property-scoped:
GET /v1.0/users?$search="displayName:Adele" OR "mail:adele"
ConsistencyLevel: eventualRules of thumb:
- Use
$filterfor exact matches, ranges, and structured predicates.
- Use
$searchfor "find me anything that mentions Adele" — fuzzy, ranked, multi-property.
$searchstrings must be double-quoted inside the query value ("displayName:Adele"). The query value itself is then URL-encoded.
$searchalways requiresConsistencyLevel: eventualon directory resources.
If you find yourself writing $filter=contains(displayName,'adele') or contains(mail,'adele') or contains(givenName,'adele'), stop and use $search instead. It is what the parameter exists for, and the relevance ranking gives users a much better experience than a literal substring match.
---
Common Pitfalls
A short list of the OData query mistakes I see most often in code reviews:
- Filtering a property that is not stored, like
signInActivity.lastSignInDateTimewithout the licence. Graph returns 400 or 403. Check the resource's required permissions and licence prerequisites before adding any filter on a less-common property.
- Forgetting that string comparison is case-sensitive on most directory properties.
mail eq 'Adele@contoso.com'will not match a user whose mail is stored lowercase. Normalise first or usetolower(mail) eq 'adele@contoso.com'.
- Mixing
$expandwith$topon a relationship endpoint.$topapplies to the outer collection, not the expanded one. To limit related entities, expand a specific subset:$expand=members($top=10)— and remember it only works on resources that explicitly support it.
- Forgetting that
$countis a query parameter, not a path segment, on collection endpoints.GET /users/$countis a valid path that returns just a number;GET /users?$count=trueis a different thing that returns the collection plus the count. Both are useful, but they are not the same call.
- Using
$filterfor a case-insensitive substring on a million-row tenant. Even with advanced query parameters this is slow. Reach for$searchor build a proper directory-search UX.
---
Putting It Together — A Realistic User Picker Query
A user picker control needs to find the top 10 enabled users matching what the operator typed, ordered by display name, with their manager and a couple of properties. The right query looks like this:
GET /v1.0/users
?$filter=accountEnabled eq true
&$search="displayName:adele" OR "mail:adele"
&$select=id,displayName,mail,userPrincipalName,jobTitle
&$expand=manager($select=id,displayName)
&$top=10
&$count=true
ConsistencyLevel: eventualIn SDK form:
const result = await client
.api("/users")
.header("ConsistencyLevel", "eventual")
.count(true)
.filter("accountEnabled eq true")
.search('"displayName:adele" OR "mail:adele"')
.select("id,displayName,mail,userPrincipalName,jobTitle")
.expand("manager($select=id,displayName)")
.top(10)
.get();That single call replaces what naive code would do as: list all users, filter client-side, fetch each user's manager separately, sort. The naive version is six round trips per keystroke and breaks at 1,000+ users. This version is one round trip and scales linearly.
---
FAQ
Do these query parameters work the same on /beta? Mostly yes. A few advanced filters land on /beta first; check the docs for the specific resource. Production code should stay on /v1.0.
Does $filter count against my throttling budget more than no filter? No. The cost is per-resource per-app, not per-property. A heavily filtered call costs the same as an unfiltered one. Filter aggressively.
Can I combine $filter and $search in the same call? On /users, /groups, /applications — yes, with ConsistencyLevel: eventual. On other resources — usually no; the docs will tell you.
What about $apply for aggregation? Graph supports a small subset of $apply (mostly on usage reports). For real aggregation you want either Microsoft Graph Data Connect or to fetch and aggregate in your own code. Do not lean on $apply for general analytics.
How do I debug a 400 from a complex query? Strip the query parameters one at a time until the request succeeds, then add them back. Graph's error message tells you which parameter is at fault but rarely tells you why. Iterative bisection is faster than reading the spec.
---
Wrap-Up
Get OData query parameters right and your Graph integration is fast, cheap, and predictable. Get them wrong and you ship code that works fine in dev, then dies the first time it hits a real-tenant directory. The patterns to internalise are: $select on every call, $filter with single-quoted strings and ISO dates, advanced query parameters (ConsistencyLevel: eventual + $count=true) for any non-trivial filter on directory objects, $expand to avoid round trips, $search for fuzzy text, $skiptoken (via nextLink) for paging.
For deeper dives on the surrounding Graph topics, see Microsoft Graph $batch: 20 API Calls in One Request, Microsoft Graph Throttling: Surviving 429s, and Microsoft Graph Delta Query: Incremental Sync. Each one composes with the OData parameters covered here, and together they cover the operational triangle — efficient queries, batched round trips, and graceful retries — that production Graph code lives or dies by.