PowerShell Graph API Pagination: Handle Large Result Sets

Why Pagination Is Mandatory for Graph Queries
The Microsoft Graph API returns a maximum of 999 objects per page for most endpoints. Scripts that call Get-MgUser or Invoke-MgGraphRequest without handling pagination silently receive a partial result set and carry on as if the data is complete. In a tenant with 2,000 users, that means a compliance script auditing all accounts misses 1,001 of them without any warning. Every tenant-wide Graph query must follow @odata.nextLink tokens until they are exhausted, or use the SDK’s -All parameter where it is reliably supported.
Quick Answer
For SDK cmdlets on v1.0 endpoints, use the -All switch: Get-MgUser -All. For custom queries or beta endpoints, loop over @odata.nextLink with Invoke-MgGraphRequest until the response contains no next link. Handle HTTP 429 responses by reading the Retry-After header and sleeping before retrying.
Understanding @odata.nextLink in Graph Responses
When a Graph response is paginated, the JSON payload includes an @odata.nextLink property containing a full URL to the next page. This URL already embeds the continuation token — you do not construct it yourself. The final page of results has no @odata.nextLink property, which is the loop termination condition. Some endpoints also use @odata.deltaLink for delta queries, which follows the same pattern but has different semantics.
# Inspect a raw paginated response
Connect-MgGraph -Scopes "User.Read.All"
$uri = "https://graph.microsoft.com/v1.0/users?`$top=5&`$select=displayName,mail"
$response = Invoke-MgGraphRequest -Uri $uri -Method GET
Write-Host "Items in this page : $($response.value.Count)"
Write-Host "Next link present : $($null -ne $response.'@odata.nextLink')"
Write-Host "Next link : $($response.'@odata.nextLink')"
Items in this page : 5
Next link present : True
Next link : https://graph.microsoft.com/v1.0/users?$top=5&$skiptoken=X%274453707402...
Building a Generic Get-GraphAllPages Helper Function
Encapsulate the pagination loop in a reusable function. This keeps calling code clean and ensures pagination is handled consistently across every Graph query in your scripts. The function accepts any initial URI and returns all objects across all pages as a single flat array.
function Get-GraphAllPages {
param(
[Parameter(Mandatory)]
[string]$Uri
)
$results = [System.Collections.Generic.List[object]]::new()
$nextUri = $Uri
do {
$response = Invoke-MgGraphRequest -Uri $nextUri -Method GET -ErrorAction Stop
foreach ($item in $response.value) { $results.Add($item) }
$nextUri = $response.'@odata.nextLink'
Write-Verbose "Fetched $($results.Count) records so far..."
} while ($nextUri)
return $results
}
# Usage
$allUsers = Get-GraphAllPages -Uri "https://graph.microsoft.com/v1.0/users?`$select=displayName,mail,userPrincipalName"
Write-Host "Total users retrieved: $($allUsers.Count)"
Using Get-MgUser with the -All Parameter in the SDK
For v1.0 SDK cmdlets, the -All switch handles pagination internally. It is the simplest approach when working with supported endpoints. The caveat is that beta endpoint cmdlets do not reliably support -All — on some beta resources it silently returns only the first page. Always verify the count against a known tenant total when using beta endpoints.
# -All handles pagination automatically on v1.0
$allUsers = Get-MgUser -All -Property DisplayName,Mail,AccountEnabled,UserPrincipalName
Write-Host "Users returned: $($allUsers.Count)"
# Groups also support -All
$allGroups = Get-MgGroup -All -Property DisplayName,GroupTypes,MembershipRule
Write-Host "Groups returned: $($allGroups.Count)"
# Verify for large tenants - cross-check with directory summary
$dirInfo = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/organization" -Method GET
Write-Host "Tenant: $($dirInfo.value[0].displayName)"
Manual Pagination with Invoke-MgGraphRequest
When querying beta endpoints, custom OData filters, or resources with no SDK cmdlet, manual pagination with Invoke-MgGraphRequest is the reliable path. Combine this with a progress counter to give operators visibility into long-running enumerations. The pattern below adds a configurable page size using the $top query parameter.
$pageSize = 999 # Maximum allowed by Graph API
$uri = "https://graph.microsoft.com/beta/users?`$top=$pageSize&`$select=id,displayName,signInActivity"
$allRecords = [System.Collections.Generic.List[object]]::new()
$page = 0
do {
$page++
$resp = Invoke-MgGraphRequest -Uri $uri -Method GET -ErrorAction Stop
$allRecords.AddRange($resp.value)
$uri = $resp.'@odata.nextLink'
Write-Progress -Activity "Fetching users" -Status "Page $page — $($allRecords.Count) records"
} while ($uri)
Write-Progress -Activity "Fetching users" -Completed
Write-Host "Total records: $($allRecords.Count)"
Handling 429 Throttling with Retry-After Header
Graph API enforces per-app and per-user throttling limits. When a request is throttled, the API returns HTTP 429 with a Retry-After header containing the number of seconds to wait before retrying. The SDK does not automatically handle throttling — your code must catch the 429 response, read the header, sleep for the specified duration, and retry. Failing to handle this causes scripts to fail mid-enumeration in large tenants.
function Invoke-GraphWithRetry {
param(
[string]$Uri,
[int]$MaxRetries = 5
)
for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) {
try {
return Invoke-MgGraphRequest -Uri $Uri -Method GET -ErrorAction Stop
}
catch {
if ($_.Exception.Response.StatusCode -eq 429) {
$retryAfter = [int]($_.Exception.Response.Headers['Retry-After'] ?? 30)
Write-Warning "Throttled. Waiting ${retryAfter}s before retry $attempt of $MaxRetries"
Start-Sleep -Seconds $retryAfter
}
else { throw }
}
}
throw "Max retries exceeded for URI: $Uri"
}
Common Errors
- SDK -All parameter silently fails on beta endpoints. The
-Allswitch is a v1.0 feature. On beta endpoint cmdlets it may return only the first page without error. Always validate the record count for beta queries by comparing against a known total or by using manual pagination withInvoke-MgGraphRequest. - Retry-After header must be parsed as an integer. The header value is a string representing seconds. Passing it directly to Start-Sleep without casting to
[int]causes a type error. Some responses also return a timestamp instead of a seconds count — check the format before parsing.
Related Cmdlets / See Also
Wrapping Up
Silent partial results are worse than an obvious error. Every tenant-wide Graph query must either use the SDK’s -All switch on a verified v1.0 endpoint, or implement a @odata.nextLink loop manually. Add 429 retry handling from the start — throttling is not an edge case in large tenants, it is the expected operating condition for batch enumeration scripts.


