Microsoft Graph Delta Query in PowerShell
Learn how to use Microsoft Graph delta queries with PowerShell and PSGraphToolbox to perform incremental sync of Entra ID users, groups, and devices.
This article explains how to use Sync-GtGraphResourceDelta from PSGraphToolbox to build stateful synchronization on top of Microsoft Graph delta queries.
On the first run, the command performs a full synchronization and stores the returned delta token along with the current resource state. On subsequent runs, it uses the stored delta link to retrieve only new, updated, or deleted objects.
The module manages paging, state persistence, merging, relationship annotations, and token expiration handling automatically. If a delta token expires, it performs a full resynchronization.
This approach enables reliable and scalable incremental synchronization without manually handling delta links, skip tokens, or replay scenarios.
How Delta Queries Work
The process runs in two phases. The first run returns the full dataset. Later runs return only the changes.
- Initial Request (Full Sync)
- Returns a full snapshot of the resource collection
- The response includes an
@odata.deltaLinktoken - You store this token for subsequent requests
- Subsequent Requests (Delta Sync)
- Send requests using the stored
@odata.deltaLink - Returns only changes since the previous call
- Includes additions, updates, and deletions
- Send requests using the stored
Running Delta Queries with PSGraphToolbox
If you haven’t already, install PSGraphToolbox and connect to Microsoft Graph:
1
2
Install-Module PsGraphToolbox -Scope CurrentUser
Connect-MgGraph -Scopes "User.Read.All", "Group.Read.All"
PSGraphToolbox is maintained by me. You can find it on GitHub.
Initial Sync
The first time you run a delta query, the function performs a full sync:
1
2
3
4
5
6
7
8
9
10
11
12
$syncParams = @{
ResourcePath = "users/delta"
SelectProperties = "id,userPrincipalName,accountEnabled,displayName"
StorageProvider = "JsonFile"
StoragePath = ".\userDeltaState.json"
}
$result = Sync-GtGraphResourceDelta @syncParams
VERBOSE: 2026-01-29 08:09:27: No previous state found: performing full sync
VERBOSE: 2026-01-29 08:09:27: Performing initial (full) sync - users/delta
VERBOSE: 2026-01-29 08:10:36: Initial sync complete: 7976 items retrieved
VERBOSE: 2026-01-29 08:10:38: State written to .\userDeltaState.json
The state file contains the full dataset plus the delta token for future queries.
The function uses a pluggable storage provider. Currently, only JsonFile is supported. For custom storage providers, see the storage provider implementation.
Incremental Sync
Run the same command again and you receive only changes:
1
2
3
4
5
$result = Sync-GtGraphResourceDelta @syncParams
VERBOSE: 2026-01-29 10:04:21: Performing incremental delta query (last sync: 2026-01-29T08:10:22Z)
VERBOSE: 2026-01-29 10:04:22: Processing 168 changed entities (current entity count: 7976)
VERBOSE: 2026-01-29 10:04:23: Incremental sync complete: UPDATE: 165, ADD: 3
VERBOSE: 2026-01-29 10:04:23: State written to .\userDeltaState.json
The function reads the stored delta token, queries for changes, and updates the state file. CurrentState contains the full, up-to-date dataset. Only the changed objects were retrieved from Graph.
Other Resources
The same pattern works for any Microsoft Graph resource that supports delta queries. See Microsoft’s documentation for the full list.
1
2
Sync-GtGraphResourceDelta -ResourcePath "devices/delta" -StoragePath ".\devicesDeltaState.json"
Sync-GtGraphResourceDelta -ResourcePath "groups/delta" -StoragePath ".\groupsDeltaState.json"
Understanding the Output
Sync-GtGraphResourceDelta returns a result object and persists the current state to your configured storage for the next sync.
1
2
3
4
5
6
7
8
9
10
11
12
$syncResult
├── ChangeLog[] Array of changes since last sync
│ ├── entityId Entity GUID
│ ├── entityName Best-effort name (UPN or displayName)
│ ├── change ADD | UPDATE | REMOVE
│ └── description Human-readable summary
├── CurrentState
│ ├── value[] All entities, merged and up-to-date
│ ├── deltaLink (internal) Next sync URL with token
│ ├── dateRetrieved (internal) Last sync timestamp
│ └── queryId (internal) Parameter fingerprint
└── ItemCount Total entity count
Properties marked (internal) are managed automatically. The queryId detects parameter changes: if you modify SelectProperties or Filter, the function triggers a full resync to prevent inconsistent state.
Quick Usage
1
2
3
4
5
6
7
8
9
10
11
# Full current dataset
$result.CurrentState.value
# Change log since last sync
$result.ChangeLog
# Discover available properties
$result.CurrentState.value | Get-Member -MemberType NoteProperty
# Select explicitly for output such as export, gridview, etc.
$result.CurrentState.value | Select-Object id, userPrincipalName, displayName
Delta responses are sparse: Graph might only return properties with values. When working with collections, use
Select-Objectwith explicit properties rather thanSelect-Object *, which formats columns based on the first object.
Handling Delta Queries for Large Group Memberships
Known Limitation: Groups with large membership lists can return incomplete results during the initial full sync. To ensure a reliable starting state, use the hybrid approach described below.
The workaround is to seed the state manually using a standard GET for the initial snapshot, then let delta queries track changes from that point forward.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
$groupId = "d21ee78c-5bb4-499e-881c-55f77898ade8"
$queryParameters = @{
SelectProperties = "id,displayName,members"
Filter = "id eq '$groupId'"
ResourcePath = "beta/groups/delta"
StoragePath = ".\groupMembersDeltaState.json"
}
# Step 1: Run delta sync to get a deltaLink, but skip writing state
$groupMembersDelta = Sync-GtGraphResourceDelta @queryParameters -NoWrite
# Step 2: Fetch the current group and members via standard GET
$groupObject = Invoke-GtGraphRequest -resource "groups/$groupId" -select "displayName"
$groupMembers = Invoke-GtGraphRequest -resource "groups/$groupId/members" -select "id"
# Step 3: Build the correct initial state and persist it
$state = [PSCustomObject]@{
id = $groupId
displayName = $groupObject.displayName
"members@delta" = $groupMembers
}
$groupMembersDelta.CurrentState.value = $state
Set-GtDeltaStateToJsonFile -path $queryParameters.StoragePath -state $groupMembersDelta.CurrentState
After initialization, run delta sync normally:
1
$groupMembersDelta = Sync-GtGraphResourceDelta @queryParameters
Considerations and Limitations
- Delta tokens expire after 30 days of inactivity. The function handles expiration by performing a full resync, but running syncs more frequently avoids the overhead.
- Not all resources support delta queries. Check Microsoft’s documentation for supported resources.
$selectaffects what changes are tracked. Graph only tracks changes to the properties you select. Changes to non-selected properties do not appear in delta results.$filterhas limited support in delta queries. It only supports scoping by object ID.
Resources
PSGraphToolbox also includes Invoke-GtGraphBatchRequest for bulk API operations. See the repo for details.