Synchronizing Microsoft 365 Users with Graph Delta Queries: A Practical Guide
Learn how to keep an external system in sync with Microsoft 365 users using Graph Delta queries. See a step‑by‑step example, paging logic, and how to handle deletions and expired links.
23 Aug 2026, 17:24 UTC

Why Delta Queries Matter for User Sync
When an organization keeps an external database in sync with Azure AD, a common pain point is the need to pull the entire user list every time. That approach is slow, bandwidth‑heavy, and can hit API limits. Microsoft Graph’s $delta parameter lets you request only the changes that occurred since the last sync, dramatically reducing traffic and processing time.
Core Concepts in a Nutshell
- Initial Full Snapshot – The first call to
/users?$deltareturns every user and adeltaLinktoken. - Delta Link – A URL‑encoded token that represents the state of the directory at the time of the snapshot. Use it for subsequent incremental syncs.
- Removed Facet – When a user is deleted, the response contains an
@removedobject instead of omitting the record. - Pagination – If the result set is large, Graph adds an
@odata.nextLinkuntil the final page, which then includes the newdeltaLink. - Expiration – Delta links are short‑lived (typically a few hours). If you miss a sync window, you must perform a full snapshot again.
Step‑by‑Step Example: Syncing Users with PowerShell
Below is a minimal script that demonstrates the entire flow. Replace the placeholders with your tenant information and run the script from a machine that has the Microsoft.Graph.Users.Read.All application permission granted.
# Variables – fill in your own values
$tenantId = "<TENANT_ID>"
$clientId = "<CLIENT_ID>"
$clientSecret = "<CLIENT_SECRET>"
$resource = "https://graph.microsoft.com/"
# 1. Acquire an app‑only token
$body = @{ grant_type = "client_credentials"
client_id = $clientId
client_secret = $clientSecret
scope = "https://graph.microsoft.com/.default" }
$tokenResponse = Invoke-RestMethod -Method Post -Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token" -Body $body
$accessToken = $tokenResponse.access_token
# 2. First sync: get full snapshot and deltaLink
$headers = @{ Authorization = "Bearer $accessToken" }
$deltaUrl = "https://graph.microsoft.com/v1.0/users?$delta"
$response = Invoke-RestMethod -Headers $headers -Uri $deltaUrl
# Store the deltaLink for next run
$deltaLink = $response.'@odata.deltaLink'
Write-Host "Initial sync complete – deltaLink stored."
# 3. Subsequent sync – use the stored deltaLink
# (In a real implementation, read $deltaLink from a persistent store)
$nextUrl = $deltaLink
$hasMore = $true
while ($hasMore) {
$page = Invoke-RestMethod -Headers $headers -Uri $nextUrl
foreach ($user in $page.value) {
# Handle created/updated users
Write-Host "User: $($user.displayName) – ID: $($user.id)"
}
if ($page.'@odata.nextLink') {
$nextUrl = $page.'@odata.nextLink'
} elseif ($page.'@odata.deltaLink') {
# Final page – update stored deltaLink
$deltaLink = $page.'@odata.deltaLink'
Write-Host "Updated deltaLink – ready for next sync."
$hasMore = $false
} else {
$hasMore = $false
}
}
# 4. Handle deletions – @removed facet
# When a user is deleted, the response contains:
# { "@removed": { "reason": "deleted" }, "id": "user-id" }
# Your sync logic should interpret this and remove the user from the external system.
Common Pitfalls and How to Avoid Them
- Delta Link Expiration – Because delta links can expire, schedule your syncs within the link’s validity window (usually 4‑6 hours). If a run fails or is delayed, fall back to a full snapshot and re‑store the new delta link.
- Large Result Sets – A single sync may produce dozens of pages. Ensure your client follows
@odata.nextLinkuntil the final page. Timeouts can occur if you process too many items in one request; consider throttling or parallel paging. - Property Changes Not Captured – Some property updates (e.g., certain profile photos) are not surfaced in delta responses. If you rely on those fields, supplement the delta sync with a periodic full sync for those attributes.
- Missing @removed Entries – If a user is deleted during a sync window, the
@removedobject appears in the same page as the deletion. Your code must check for this property before assuming the user exists.
Verifying Your Implementation
- Run the initial sync and confirm that all users appear in your external system.
- Modify a user’s
jobTitlefield in Azure AD and execute the delta sync. The updated value should be reflected. - Delete a user and run the delta sync again. Verify that the
@removedfacet is present and that your system removes the user record. - Wait until the stored delta link expires (you can force expiration by waiting >6 hours) and attempt a delta sync. The API should return a full snapshot, allowing you to rebuild the delta link.
Trade‑off: Delta vs. Full Sync
Delta queries are ideal for daily or hourly syncs where the number of changes is low relative to the total user base. However, if your organization experiences a high churn rate (hundreds of creations or deletions per hour), the overhead of paging and processing many delta pages may outweigh the benefits. In that scenario, a scheduled full sync every few hours, combined with a delta sync for incremental changes, can provide a balanced approach.
Actionable Take‑aways
- Implement delta sync in your automation pipeline and store the
deltaLinkpersistently. - Use
@odata.nextLinkto handle paging reliably; do not assume a single response. - Handle
@removedobjects to keep the external dataset clean. - Monitor delta link expiration and design a fallback path to a full snapshot.
- Document the sync cadence and thresholds to avoid hitting Graph throttling limits.
By following these steps, you’ll maintain an efficient, low‑overhead sync between Azure AD and your external systems, ensuring data consistency without unnecessary API calls.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.