Stop syncing entire tables when you only need what changed. This expert-level lesson teaches you how Dataverse delta queries work at the OData protocol level, how to persist delta tokens reliably, and how to build an incremental sync pipeline that handles token expiration, pagination, and concurrent execution without breaking production.

Picture this: your organization runs a Dynamics 365 Sales environment with 400,000 Account records. Every night, a scheduled Power Automate flow queries the entire Accounts table to push updates into your enterprise data warehouse. On a good night it takes 90 minutes. On a bad night—when someone ran a bulk import during business hours or the API starts throttling—it fails at minute 47 with no clear recovery path. The next morning, your analytics team is working with yesterday's data, your integration team is firefighting, and your Power Platform admin is staring at a retry queue full of 429 errors.
This is the problem that Dataverse Change Tracking and delta queries were built to solve. Instead of asking "give me everything," you ask "give me what changed since the last time I checked." The API returns only the records that were created, updated, or deleted in that window—potentially reducing your payload from 400,000 rows to 200 rows. Your flow finishes in seconds instead of hours, your throttling exposure collapses, and your downstream systems receive genuinely fresh data.
By the end of this lesson, you'll understand how change tracking works at the Dataverse API level, how to implement a production-grade incremental sync pipeline in Power Automate using HTTP actions and delta tokens, and how to handle the hard edge cases—token expiration, full resync recovery, concurrent execution, and table schema changes—that separate a proof-of-concept from a system you can trust in production.
What you'll learn:
Before diving in, you should be comfortable with:
@odata.nextLink works when querying large datasets. If not, read Handling Pagination and Throttling When Querying Large Datasets in Power Automate first.Before writing a single action in the designer, you need to understand what's happening under the hood. This isn't just academic — the design decisions you'll make in your pipeline flow directly from how the Dataverse change tracking mechanism works.
Dataverse change tracking is built on the OData v4 delta query specification. The core idea is simple but elegant: when you make an initial query, the API returns your records and a special @odata.deltaLink token embedded in the response. That token is an opaque cursor that encodes the state of the dataset at the time of your query.
On your next run, instead of calling the original endpoint again, you call the @odata.deltaLink URL. The API compares current state to the state at your token's timestamp and returns only the records that have changed. Each response again includes a new @odata.deltaLink, which you save for the next run.
The Dataverse-specific implementation has a few important characteristics:
Deleted records are returned as "tombstones" — objects with the record's ID and a special @odata.context annotation indicating deletion. The response body looks like:
{
"@odata.context": "https://yourorg.crm.dynamics.com/api/data/v9.2/$metadata#accounts/$deletedEntity",
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"reason": "deleted"
}
This is critical: if you're only looking at the value array in your response and processing each item identically, you'll miss deletes entirely, or worse, you'll try to update a record in your target system with an ID that no longer has any field data.
Updated records return only the entity ID and the fields that changed if you're using selective change tracking — but in practice, Dataverse returns the full row for changed records by default. This is actually useful because you don't need to merge partial updates.
The token expiration window is configurable but defaults to 3 days in most Dataverse environments. If your flow doesn't run for longer than this window, the delta token becomes invalid and you must perform a full resync. This is the single most important operational constraint to design for.
Change tracking is not enabled by default on custom tables. You need to turn it on explicitly before any delta tokens will work.
In the Power Apps maker portal (make.powerapps.com), navigate to your environment and open the table you want to track. In the table settings (the gear icon in the table editor), look for the Properties panel. You'll find a checkbox labeled "Track changes." Enable it and save.
For standard Dataverse tables like Account, Contact, Lead, and Opportunity, change tracking is already enabled. For custom tables created in your solutions, you'll need to enable it manually.
Warning: Enabling change tracking on a large, heavily-used table has a mild performance cost on the Dataverse write path because the platform needs to maintain additional metadata per record. For tables with very high write volume (thousands of records per minute), test the impact in a non-production environment first.
You can also enable change tracking programmatically via the Dataverse Web API or using the XRM SDK. In a solution-aware deployment, this setting lives in the table's metadata and travels with your solution across environments. This is worth doing — you don't want to deploy to production and discover you forgot to flip the switch.
Before building anything in Power Automate, sketch the architecture on paper. Incremental sync pipelines have four distinct responsibilities, and conflating them in a single flow causes fragility.
1. Token Management — Reading the current delta token from storage, and writing the new token after a successful sync.
2. Delta Query Execution — Calling the Dataverse API with the current token, handling pagination (because a delta response can span multiple pages), and assembling the full change set.
3. Change Processing — Differentiating creates, updates, and deletes, then writing each to the target system with appropriate operations.
4. Error Recovery — Detecting token expiration, triggering full resync when necessary, and alerting on failures.
You have three realistic options:
Option A: A Dataverse "Sync State" table — Create a custom table with columns for TableName, DeltaToken, LastSuccessfulSync, and SyncStatus. This keeps everything within the Microsoft ecosystem and is queryable. The downside is that if Dataverse itself is having issues, your token store is unavailable at the same time you most need it.
Option B: Azure Table Storage — Cheap, fast, and completely independent of Dataverse. A single row per table being synced, updated atomically using the ETag concurrency mechanism. This is the option we'll use in this lesson because it's the most operationally robust.
Option C: SharePoint List — Convenient but slower and subject to SharePoint throttling, which you absolutely don't want your sync pipeline to be sensitive to. Avoid this for high-frequency pipelines.
We'll build two flows:
Flow 1: Incremental Sync Orchestrator (Scheduled) — Runs on a schedule (every 5 minutes for near-real-time, every hour for batch). Reads the token, executes the delta query, processes changes, and writes the new token. This is the primary happy-path flow.
Flow 2: Full Resync Recovery (Manually triggered or error-triggered) — Performs a full table sync, generates a fresh initial delta token, and writes it to storage. You trigger this flow on initial setup, after a token expiration, or after a schema change.
This separation means your recovery logic doesn't pollute your hot path. It also means you can trigger a full resync without modifying the incremental flow.
Key insight: A common anti-pattern is building the full resync into the incremental flow as an
elsebranch. This sounds elegant but means your scheduled flow occasionally runs for hours when it hits token expiration — which causes it to overlap with the next scheduled run, which causes concurrency issues. Keep the paths separate.
Let's get concrete. Here's how to call the Dataverse delta query endpoint from a Power Automate HTTP action.
For production use, you should use an Azure AD app registration with the Dataverse API permission. Store the client secret in Azure Key Vault — see Integrating Power Automate with Azure Key Vault and Managed Identities: Complete Guide to Secrets Management and Zero-Trust Authentication for the complete pattern.
For our flow, assume you have:
tenantId: Your Azure AD tenant IDclientId: The app registration's client IDclientSecret: Retrieved from Key Vault at flow startFirst, obtain a bearer token with an HTTP action:
Method: POST
URI: https://login.microsoftonline.com/@{variables('tenantId')}/oauth2/v2.0/token
Headers:
Content-Type: application/x-www-form-urlencoded
Body:
grant_type=client_credentials
&client_id=@{variables('clientId')}
&client_secret=@{variables('clientSecret')}
&scope=https://yourorg.crm.dynamics.com/.default
Parse the response with a Parse JSON action to extract access_token.
When you have no delta token stored yet, you make an ordinary query with a special Prefer header:
Method: GET
URI: https://yourorg.crm.dynamics.com/api/data/v9.2/accounts
?$select=accountid,name,emailaddress1,telephone1,modifiedon,statecode
&$filter=statecode eq 0
&trackChanges=true
Headers:
Authorization: Bearer @{body('Parse_Bearer_Token')?['access_token']}
OData-MaxVersion: 4.0
OData-Version: 4.0
Accept: application/json
Prefer: odata.track-changes, odata.maxpagesize=5000
The trackChanges=true query parameter activates delta tracking. The Prefer: odata.track-changes header tells the API to include the @odata.deltaLink in the response. Both are required.
Note: The
$filterparameter in your initial query becomes baked into the delta link. Future delta calls will automatically apply the same filter — you don't need to repeat it. This means your filter is set at initialization time. If you need to change the filter later (for example, adding a new column), you'll need to regenerate the delta token via a full resync.
Once you have a delta link stored, calling it is much simpler:
Method: GET
URI: @{variables('CurrentDeltaToken')}
Headers:
Authorization: Bearer @{body('Parse_Bearer_Token')?['access_token']}
OData-MaxVersion: 4.0
OData-Version: 4.0
Accept: application/json
Prefer: odata.maxpagesize=5000
Notice you don't need the Prefer: odata.track-changes header here — the delta link already encodes that behavior. The URI is the complete delta link string, which looks something like:
https://yourorg.crm.dynamics.com/api/data/v9.2/accounts?$deltatoken=05671b15-6df7-4b2e-a33a-b56f5f7234bc_1700000000000
A delta response can itself be paginated. When the change set is large, the API returns @odata.nextLink for intermediate pages and only @odata.deltaLink on the final page. Your pipeline must follow all the nextLink pages before it can get the new delta token.
Here's the pagination loop structure in Power Automate:
NextLink to the delta query URI.AllChangedRecords as an empty array.NextLink is equal to "DONE".NextLink.value array from the response to AllChangedRecords using union() or a compose + set variable pattern.@odata.nextLink exists in the response body. If yes, set NextLink to that value. If no, check if @odata.deltaLink exists — set it to a variable NewDeltaToken and set NextLink to "DONE" to break the loop.The expression to check for the presence of @odata.deltaLink:
if(
contains(body('HTTP_Delta_Query'), '@odata.deltaLink'),
body('HTTP_Delta_Query')['@odata.deltaLink'],
null
)
Warning: Do not save the new delta token until after all pages have been processed AND all changes have been successfully written to your target system. If you save the token mid-process and then fail, you'll have a new token that skips over changes you never applied. Token writes are your "commit" operation — treat them with transaction-like discipline.
For a deeper look at how to structure and optimize these loops, the patterns in Implementing Parallel Branching and Concurrency Control in Power Automate to Maximize Throughput and Prevent Race Conditions are directly applicable to the parallelization opportunities in your change processing stage.
Once you've assembled the full AllChangedRecords array across all pages, you need to process each record correctly. The tricky part is distinguishing creates/updates from deletes.
Deleted records in a delta response don't have normal entity properties. They have an @odata.context property that ends with /$deletedEntity and an id property instead of the entity's primary key column name (accountid in our example).
In your processing loop, check for deletion before doing anything else:
// Expression to check if record is a delete tombstone:
contains(
string(items('Apply_to_each_Changed_Record')),
'$deletedEntity'
)
Alternatively, check for the id property specifically:
if(
contains(items('Apply_to_each_Changed_Record'), 'id'),
true, // This is a delete
false // This is a create or update
)
This works because regular Dataverse entities use accountid (or whatever the primary key column is), not id. The deleted entity tombstone uses the generic id property.
Structure your change processing with a Condition action at the top:
If delete:
id from the tombstone: items('Apply_to_each_Changed_Record')?['id']If create or update:
For most warehouse-style targets, an UPSERT by the Dataverse record ID is the right pattern — you don't need to distinguish creates from updates. The record either exists in your target (update it) or it doesn't (insert it).
Here's what the processing section looks like for an Azure SQL target using the SQL connector:
// Inside your Apply to each (AllChangedRecords):
[Condition: Is Delete?]
Yes branch:
- SQL: Execute a stored procedure
Procedure: usp_DeleteSyncedAccount
Parameters:
@AccountId: items('Apply_to_each_Changed_Record')?['id']
No branch:
- SQL: Execute a stored procedure
Procedure: usp_UpsertAccount
Parameters:
@AccountId: items('Apply_to_each_Changed_Record')?['accountid']
@Name: items('Apply_to_each_Changed_Record')?['name']
@Email: items('Apply_to_each_Changed_Record')?['emailaddress1']
@Phone: items('Apply_to_each_Changed_Record')?['telephone1']
@ModifiedOn: items('Apply_to_each_Changed_Record')?['modifiedon']
@StateCode: items('Apply_to_each_Changed_Record')?['statecode']
Using stored procedures is deliberately preferred here over raw SQL INSERT/UPDATE statements — it keeps your SQL logic out of your flow, makes schema changes easier to manage, and gives you transaction control at the database level.
Tip: If your change sets are large (hundreds of records per run), serialize the entire
AllChangedRecordsarray as JSON and pass it to a single stored procedure that usesOPENJSONto process the batch in one database round trip. This is dramatically faster than one stored procedure call per record and avoids throttling on the SQL connector, which counts API calls against your Power Automate plan limits.
Now let's implement the token store. Azure Table Storage is accessed via the HTTP connector in Power Automate — there's no dedicated connector, which actually works in our favor because it gives us precise control over the request.
Your Azure Storage account needs a table (let's call it SyncState) with these logical columns:
"Dataverse" (constant)"account")"Active", "Resyncing", or "Failed"Use an HTTP action with the Azure Storage Shared Key or SAS token for auth:
Method: GET
URI: https://yourstorage.table.core.windows.net/SyncState(PartitionKey='Dataverse',RowKey='account')
Headers:
Accept: application/json;odata=nometadata
x-ms-date: @{utcNow('R')}
Authorization: SharedKeyLite yourstorage:@{base64(hmac-sha256-signature)}
Note: Computing the SharedKeyLite HMAC-SHA256 signature in a Power Automate expression is painful. In practice, use a SAS token scoped to the specific table with read/write permissions and a reasonable expiry. Store the SAS token in Azure Key Vault and retrieve it at flow start, just like your Dataverse credentials.
When no token exists (new setup), the response will be a 404. Handle this in a condition: if the status code is 404, set CurrentDeltaToken to null and proceed to the initial delta query path. If 200, parse the JSON body and set CurrentDeltaToken to the DeltaToken property.
After all changes are successfully processed, write the token back:
Method: MERGE
URI: https://yourstorage.table.core.windows.net/SyncState(PartitionKey='Dataverse',RowKey='account')
Headers:
Content-Type: application/json
Accept: application/json;odata=nometadata
If-Match: *
Body:
{
"DeltaToken": "@{variables('NewDeltaToken')}",
"LastSyncAt": "@{utcNow()}",
"SyncStatus": "Active"
}
The MERGE method (Azure Table Storage's equivalent of a partial update) means you only need to provide the columns you're changing — it won't overwrite PartitionKey and RowKey.
The If-Match: * header means "overwrite regardless of current ETag." For higher concurrency safety, you can store the ETag from the read operation and use it here to implement optimistic locking — which prevents two concurrent flow runs from both writing their tokens and causing a split-brain state.
This is where most tutorial implementations fall apart. Handling token expiration gracefully is what separates a production pipeline from a fragile demo.
When you call a delta link with an expired token, the Dataverse API returns an HTTP 410 Gone response with a body like:
{
"error": {
"code": "0x80060888",
"message": "The delta token has expired. Perform a full query to get the latest data."
}
}
In your HTTP action, configure it to not fail on non-2xx responses. Do this by expanding the action settings and setting "On failure" to continue. Then check the status code in a Condition action:
Expression: outputs('HTTP_Delta_Query')['statusCode']
Condition: is equal to 410
If 410: trigger the full resync flow via "Run a Child Flow" action, update SyncStatus in your token store to "Resyncing", and terminate the current run with "Succeeded" status (so it doesn't spam error alerts for an expected condition).
If another non-200 code: that's a genuine error. Handle it with your standard error pattern — log to an error table, send an alert, terminate with "Failed."
Warning: After a 410 token expiration, do not immediately start processing records from the full resync in the same flow run. Full resyncs can involve millions of records and take hours. Trigger the resync as a separate flow and let the incremental sync flow exit cleanly. The scheduled incremental flow will do nothing useful until the resync completes and writes a fresh token, which is exactly the right behavior.
The full resync flow follows this pattern:
trackChanges=true and Prefer: odata.track-changes).@odata.nextLink pages, writing each batch to the target system. This is the bulk load phase.@odata.deltaLink as the new fresh token.SyncStatus to "Active."During the bulk load phase, performance optimization is critical. Use the patterns from Orchestrating Child Flows and Scoped Execution in Power Automate for Scalable, Reusable Automation Architecture to fan out page processing across multiple child flows in parallel, so you're not processing 80 pages of 5,000 records sequentially.
One important subtlety: during the full resync, your target system is in a partially-updated state. If the incremental sync flow fires during this window (which it will, since it's scheduled), it should detect SyncStatus = "Resyncing" and exit early without doing anything. Add this check at the very beginning of your incremental sync flow.
Your incremental sync flow should never have two instances running simultaneously against the same table. Configure the flow's concurrency settings:
In the flow trigger settings (click the three dots on the trigger in the designer), find "Concurrency Control." Enable it and set "Degree of Parallelism" to 1. This ensures if a previous run is still executing when the schedule fires again, the new run is queued (or rejected, depending on your setting) rather than starting a parallel execution.
This matters because two concurrent runs could both read the same delta token, both process the same changes, both write to your target system (duplicating work), and then both write different "new" tokens — and the loser's write will silently overwrite the winner's, causing a gap in your sync.
Even with concurrency control, you may encounter reprocessing due to flow reruns after partial failures. Your target-side write operations must be idempotent — running them twice on the same change should produce the same result as running them once.
For UPSERT operations, this is natural: inserting a record that already exists just updates it. For DELETE operations, a delete on an already-deleted record should return success (or at least not throw an error your flow will catch).
In your SQL stored procedures, handle this explicitly:
-- usp_DeleteSyncedAccount
CREATE PROCEDURE usp_DeleteSyncedAccount @AccountId UNIQUEIDENTIFIER
AS
BEGIN
DELETE FROM dbo.Accounts WHERE DataverseId = @AccountId;
-- No error if not found -- that's fine, it was already deleted
END
-- usp_UpsertAccount
CREATE PROCEDURE usp_UpsertAccount
@AccountId UNIQUEIDENTIFIER,
@Name NVARCHAR(160),
@Email NVARCHAR(100),
@Phone NVARCHAR(50),
@ModifiedOn DATETIME2,
@StateCode INT
AS
BEGIN
MERGE dbo.Accounts AS target
USING (VALUES (@AccountId, @Name, @Email, @Phone, @ModifiedOn, @StateCode))
AS source (DataverseId, Name, Email, Phone, ModifiedOn, StateCode)
ON target.DataverseId = source.DataverseId
WHEN MATCHED AND target.ModifiedOn < source.ModifiedOn THEN
UPDATE SET Name = source.Name, Email = source.Email,
Phone = source.Phone, ModifiedOn = source.ModifiedOn, StateCode = source.StateCode
WHEN NOT MATCHED THEN
INSERT (DataverseId, Name, Email, Phone, ModifiedOn, StateCode)
VALUES (source.DataverseId, source.Name, source.Email, source.Phone, source.ModifiedOn, source.StateCode);
END
Notice the AND target.ModifiedOn < source.ModifiedOn condition on the MATCH branch. This is a "last write wins" guard — if a record appears in two consecutive delta runs (possible if it was updated very quickly), you only apply the update if the incoming data is actually newer. This prevents a stale reprocessed record from overwriting a more recent version.
A sync pipeline you can't observe is a liability. At minimum, you need:
Log the following to your Dataverse or Azure SQL monitoring table at the end of every successful run:
// Expression to count deletes in your change set:
length(
filter(
variables('AllChangedRecords'),
item() => contains(string(item()), '$deletedEntity')
)
)
Two conditions should trigger immediate alerts:
Change set size spike — If your delta query returns more than, say, 10,000 records when the average is 50, something unusual happened (bulk import, data migration, misconfigured automation). This isn't necessarily a problem, but you want to know.
Processing lag growth — If successive runs consistently return large change sets without them shrinking, your processing pipeline may be slower than the rate of changes. You're falling behind, and eventually your delta token will expire before you ever catch up. Alert on this and consider switching to a higher-frequency schedule or parallelizing your write operations.
Tip: Power BI can consume your monitoring log table directly and give you a live dashboard showing sync lag, change volume trends, and error rates over time. This is far more useful than watching flow run history in Power Automate. Connect the dashboard to your Dataverse sync monitoring table and schedule an auto-refresh. See Building Real-Time Power BI Dataset Refresh Pipelines with Power Automate: Triggering, Monitoring, and Alerting for the refresh pipeline pattern.
The odata.maxpagesize preference controls how many records Dataverse returns per page. The maximum is 5,000 for change tracking queries (versus 1,000 for standard queries). Use 5,000 in production — each HTTP round trip has fixed overhead, and fewer round trips means lower total latency.
However, page size affects memory usage in your flow. Very large pages (5,000 records × many fields × many columns) can cause your flow to hit the 100MB expression evaluation limit. If you're syncing wide tables with many columns, consider selecting only the columns you actually need in your $select clause, and reduce page size to 2,000 or 3,000 if you start seeing expression size errors.
For change sets with hundreds or thousands of records, sequential processing in an Apply to each loop is too slow. Power Automate allows configuring concurrency on apply-to-each loops — set "Degree of Parallelism" to 10–15 for SQL upsert operations.
Warning: Parallel SQL writes against the same rows from the same change set can cause deadlocks. Design your stored procedures with proper transaction isolation, and add retry logic on the SQL connector for transient deadlock errors (SQL error code 1205). The Master Error Handling and Retry Patterns in Power Automate for Bulletproof Flows article covers retry-on-specific-error patterns you can apply here.
If a single delta run returns more than ~50,000 records (possible after a bulk operation), processing all of them in a single flow run risks hitting Power Automate's 30-day run history size limit or the flow timeout limit.
Implement a chunking strategy: after paginating through the delta response, if AllChangedRecords exceeds a threshold (say 25,000 records), write the partial batch to a staging queue in Azure Service Bus or a Dataverse staging table, then trigger a batch processor flow. This pattern is described in detail in Implementing Event-Driven Automation with Power Automate and Azure Service Bus: Queue-Based Decoupling for High-Volume, Resilient Enterprise Workflows.
The key insight here is that your sync orchestrator flow's job is to collect changes reliably and enqueue them — not necessarily to process them in the same execution context. Separating collection from processing gives you independent scaling and retry for each concern.
Build an incremental sync pipeline that keeps a SQL Server table synchronized with Dataverse Account records. Use this specification:
Target table: dbo.AccountSync in Azure SQL
Columns: DataverseId (UNIQUEIDENTIFIER, PK), AccountName (NVARCHAR(160)), PrimaryEmail (NVARCHAR(100)), SyncedAt (DATETIME2), IsDeleted (BIT)
Step 1: Enable change tracking on the Account table in your Dataverse environment via the maker portal.
Step 2: Create the token store. Create an Azure Storage account and add a SyncState table. Generate a SAS token with Table Read + Write + Update permissions.
Step 3: Build Flow 1 — Full Resync. This flow is manually triggered. It calls the Accounts endpoint with trackChanges=true, paginates through all results, upserts each to dbo.AccountSync, and on the final page, writes @odata.deltaLink to your SyncState table.
Step 4: Build Flow 2 — Incremental Sync. This flow runs every 10 minutes. It reads the delta token from SyncState, calls the delta URL, processes changes (distinguishing tombstones from regular records), upserts or soft-deletes records in dbo.AccountSync, then writes the new delta token.
Step 5: Test your pipeline. Run Flow 1 to establish the initial state. Then in Dataverse, update 3 Account records and delete 1. Run Flow 2 manually and verify that exactly 4 records appear in your change set, that the 3 updates are reflected in dbo.AccountSync, and that the deleted record has IsDeleted = 1.
Step 6: Test token expiration recovery. Manually corrupt the delta token in your SyncState table (change a few characters in the token string). Run Flow 2 and verify it receives a 410 response and handles it without crashing — ideally triggering a notification.
Stretch goal: Add monitoring. After each successful Flow 2 run, insert a row to a SyncAuditLog table with the run timestamp, change count, and run duration. Build a simple Power Automate flow that queries SyncAuditLog and sends a Teams alert if the change count exceeds 5,000 in a single run.
Check two things: first, is change tracking enabled on the table? Second, are you calling the delta link from a previous run? If you're regenerating the delta link on every run from a fresh initial query, you'll always see zero changes because you're comparing current state to current state (no time has passed). The delta link must be stored persistently between runs.
The delta link URL includes your Dataverse environment URL. If you're refreshing your bearer token each run (which you should be), make sure you're passing the fresh token — not a cached one — to the delta query. Also verify that the service principal used for authentication has the prvReadAccount privilege in Dataverse. Missing read privilege on the entity produces a 401, not a 403, which is confusing.
This is usually a $select clause issue from your initial query. If you selected name but not modifiedon in your initial query, changes that only affect modifiedon may not surface. More commonly, if you added a $filter to your initial query (like statecode eq 0), records that were previously active but became inactive won't appear in subsequent deltas — they're filtered out of the change set. This is a fundamental constraint of Dataverse delta queries: filter scope is fixed at initialization.
Power Automate cloud flows have a maximum duration of 30 days per run, but practically you'll hit performance limits much sooner with very large sequential Apply to each loops. Enable parallelism on the loop, switch to batch SQL processing (passing the entire JSON array to a stored procedure), and consider splitting your change processing into a child flow that receives a chunk of records and processes them independently.
Two likely causes: either concurrency control is not set on your flow trigger (two instances ran simultaneously and both processed the same delta), or your UPSERT logic isn't keying on DataverseId correctly. Also check whether your full resync flow and your incremental sync flow could have overlapped — the SyncStatus = "Resyncing" guard in the incremental flow is specifically designed to prevent this, but verify it's implemented correctly.
Your delta token window is shorter than you think, or your flow isn't running frequently enough. Check your Dataverse environment's change tracking retention setting (in Power Platform admin center under Environment > Settings). You can increase it up to 90 days in some configurations, though the default is 3 days. Also verify your scheduled flow isn't being throttled or silently skipping runs — check Using Power Automate Run History and Flow Checker to Debug and Fix Failing Flows for how to audit your run history for gaps.
You've built a production-grade incremental sync pipeline grounded in how Dataverse change tracking actually works at the protocol level. Let's recap what you've implemented:
Do until loop that follows @odata.nextLink through multi-page change sets before capturing the new @odata.deltaLink.ModifiedOn guards that are safe to rerun on the same data.Where to go next:
For deeper data transformation skills — particularly for mapping and reshaping the Dataverse field structures before writing to your target — the patterns in Transforming and Mapping Data with Power Automate's Compose, Select, and Filter Array Actions will give you a complete vocabulary for JSON transformation inside the flow.
If you're deploying this pipeline across multiple environments (dev, test, production), the ALM considerations in Deploying and Managing Power Automate Solutions Across Environments: ALM Pipelines, Solution-Aware Flows, and Environment Variables for Enterprise-Scale Delivery show you how to parameterize environment-specific settings (Dataverse URLs, storage account names, SAS tokens) so your flow package is truly portable.
Finally, consider where this pipeline fits in a broader event-driven architecture. A delta sync that runs every 5 minutes is near-real-time, but it's still polling. If you need sub-minute latency for specific high-priority record changes, a Service Bus–based trigger architecture (where Dataverse plugin steps push messages directly to a queue) can complement your delta sync for the events that truly can't wait.