Learn how to handle bulk data operations in Power Apps Canvas Apps without freezing the UI or hitting API throttle limits. This lesson covers the table-form Patch pattern, chunked ForAll processing, real-time progress tracking, and error handling for partial failures — everything you need to build production-quality bulk update screens.

Here's a scenario every Power Apps developer eventually runs into: your users need to approve 200 expense reports at once, or mark 500 inventory items as "audited," or update the status field on every open work order in a region. The first instinct — looping through records one at a time with a ForAll that calls Patch inside it — works fine in testing with your 12 sample rows. Then you deploy to production, someone selects 300 records, and suddenly the app crawls for two minutes before either completing or throwing a cryptic delegation warning.
This lesson is about doing bulk data operations the right way in Canvas Apps. You'll learn how the Patch function's multi-record syntax actually works under the hood, how to use ForAll efficiently for transformations before writing, how to implement a real progress tracker so users aren't staring at a frozen screen, and how to think about the tradeoffs between client-side and server-side bulk operations. By the end, you'll be able to build a production-quality bulk update interface that handles hundreds of records gracefully and gives users clear feedback throughout the process.
What you'll learn:
Patch's table-accepts-table syntax to send multi-record updates in a single callForAll with nested Patch is an anti-pattern and what to do insteadYou should already be comfortable with:
Patch and Filter formulas in Canvas AppsCollect, ClearCollect)Before we write a single formula, you need a mental model of what actually happens when you call Patch.
When you call Patch(DataSource, Record) on a single record, Power Apps makes one API call to your backend. One record, one round trip. Do that in a loop for 300 records and you've made 300 API calls. That's not just slow — it's potentially throttled. SharePoint Online has per-user, per-minute call limits. Dataverse has API concurrency limits. Your users will hit these limits in production even if they never do in development.
The good news: Patch has a form that accepts a table of records as its second argument, and in many connectors this results in batched API calls rather than individual ones. The syntax looks like this:
Patch(DataSource, TableOfChanges)
Where TableOfChanges is a table where each row contains the fields you want to update (including the record identifier so the connector knows which row to target). Power Apps will attempt to batch these into fewer API calls — typically groups of 100 records for SharePoint. That's a meaningful improvement: 300 records becomes 3 batch calls instead of 300 individual ones.
This is the first thing to internalize: the unit of efficiency in Power Apps bulk operations is the table argument to Patch, not a loop.
Important: The table-form of
Patchreturns a table of results, one row per input record, where each row either contains the updated record or an error object. You must capture and inspect this return value to know what actually succeeded.
Let me show you the pattern you'll see in a lot of tutorials — and explain exactly why it breaks under production conditions.
// DO NOT USE THIS PATTERN FOR BULK OPERATIONS
ForAll(
Filter(ExpenseReports, Status = "Pending" && SubmittedBy = currentRegion),
Patch(
ExpenseReports,
ThisRecord,
{Status: "Approved", ApprovedBy: currentUser, ApprovedDate: Today()}
)
)
This looks reasonable. ForAll iterates your filtered records, and for each one, calls Patch with the current record plus the new field values. In a test environment with 15 records, this completes in a few seconds.
The problems in production:
Problem 1: Sequential API calls. Despite what the documentation implies, ForAll in Canvas Apps does not guarantee parallel execution, and in practice, connector calls inside ForAll often execute sequentially or in small concurrent batches that you have no control over. 300 records is 300 API calls with no batching.
Problem 2: No error isolation. If record #47 fails (maybe it's locked, maybe the network hiccupped), the ForAll may halt entirely or silently skip the error. You have no reliable way to know which records failed.
Problem 3: The UI freezes. There's no mechanism here to update a progress indicator mid-loop. The user stares at a spinner until it finishes or fails.
Problem 4: Delegation blind spots. Filter inside ForAll can hit delegation limits if your filtered result set exceeds 500 or 2000 records (depending on your data source settings). You might process far fewer records than you intended, silently.
Now let me show you how to do this properly.
The core pattern for efficient bulk updates is: collect what you want to write, transform it locally, then send it all at once.
Let's use a realistic scenario: you're building a warehouse management app. A supervisor needs to mark a batch of inventory items as "Cycle Counted" — setting a status field, a timestamp, and the counter's employee ID — for anywhere from 50 to 500 items at once.
Your app has a Gallery showing inventory items. Users tap checkboxes to select rows. You maintain a collection of selected items:
// OnSelect of each checkbox in the gallery
If(
ThisItem.IsSelected,
Collect(SelectedItems, ThisItem),
Remove(SelectedItems, ThisItem)
)
Or if you're using a Gallery with built-in multi-select:
// ClearCollect on a "Select All" button
ClearCollect(
SelectedItems,
Filter(InventoryItems, Location = dropLocation.Selected.Value && LastCounted < DateAdd(Today(), -90, TimeUnit.Days))
)
This is the key insight most developers miss. Before you call Patch, build a new table in memory that contains exactly the fields you want to write, combined with the record identifiers:
// Executed when supervisor clicks "Mark as Counted"
ClearCollect(
ItemUpdates,
AddColumns(
SelectedItems,
"Status", "Cycle Counted",
"LastCountedDate", Now(),
"CountedBy", currentUser.Email,
"CountBatchID", batchIDLabel.Text
)
)
AddColumns is doing the heavy lifting here. It takes your SelectedItems collection — which contains the full original records including their IDs — and adds the four new fields you want to set. The result is a table where each row has both the record identifier (which Patch needs to find the right row in your data source) and the new values.
Notice we haven't touched the data source yet. Everything so far is purely local and instant.
Now the actual write:
ClearCollect(
PatchResults,
Patch(InventoryItems, ItemUpdates)
)
This single Patch call sends your entire ItemUpdates table to the connector. For SharePoint, this gets batched into groups of 100. For Dataverse, the behavior depends on your connector version but is similarly optimized compared to individual calls.
The return value — stored in PatchResults — is critical. It's a table of the same length as your input, where each row is either the successfully updated record or an error object.
The multi-record Patch does not fail atomically. It will happily update records 1 through 46 and 48 through 300, while record 47 fails, and return you a mixed table of successes and errors. If you don't check for errors, you'll miss these silent partial failures.
// After your Patch call, split the results
ClearCollect(
SuccessfulUpdates,
Filter(PatchResults, IsBlank(Error))
)
ClearCollect(
FailedUpdates,
Filter(PatchResults, !IsBlank(Error))
)
Wait — IsBlank(Error) on a table? Here's how this works: when Patch succeeds for a record, it returns the updated record data. When it fails, it returns a record with an Error property containing the error details. So filtering on IsBlank(Error) gives you the rows that came back as real record data (no error property present), and !IsBlank(Error) gives you the failures.
In practice, I prefer checking this way:
ClearCollect(
FailedUpdates,
Filter(
AddColumns(PatchResults, "HasError", !IsBlank(Error)),
HasError = true
)
)
Once you have FailedUpdates, you can show the supervisor which items didn't update:
// A label showing the summary
"Updated " & CountRows(SuccessfulUpdates) & " of " & CountRows(SelectedItems) & " items. " &
If(
CountRows(FailedUpdates) > 0,
CountRows(FailedUpdates) & " items failed — see the error list below.",
"All updates completed successfully."
)
For the error gallery, use FailedUpdates as the items source and show ThisItem.ItemCode, ThisItem.Description, and ThisItem.Error (which will contain the error message from the connector).
Tip: Log failures to a separate SharePoint list or Dataverse table if you need an audit trail. Create an
ErrorLogcollection fromFailedUpdatesand thenPatchthat to your logging data source as a separate operation after the main update.
Even with batched Patch, 500 records takes time. "Time" that, from the user's perspective, looks identical to "the app crashed." You need progress feedback.
The challenge: Canvas Apps formulas are synchronous. Once you start a Patch operation, you can't interrupt it to update a label. This means your progress tracking strategy depends on how you structure the operation.
For operations that complete in under 30 seconds, a simple three-state approach works well:
// App-level variable to track state: "idle", "processing", "complete", "error"
Set(operationState, "processing");
Set(processedCount, 0);
Set(totalCount, CountRows(SelectedItems));
Then a progress label formula tied to operationState:
Switch(
operationState,
"idle", "Select items and click Update to begin.",
"processing", "Processing " & totalCount & " items — please wait...",
"complete", "Done! " & processedCount & " items updated successfully.",
"error", "Operation completed with errors. " & processedCount & " succeeded, " & CountRows(FailedUpdates) & " failed."
)
After the Patch call:
Set(processedCount, CountRows(SuccessfulUpdates));
Set(operationState, If(CountRows(FailedUpdates) > 0, "error", "complete"))
This is honest with users: it tells them something is happening, then reports results when finished.
If you genuinely need mid-operation progress (think: 2000+ records that take several minutes), you need to chunk your data and process chunks sequentially. This allows Canvas Apps to update variables between chunk operations.
Here's how to build a chunked processor:
// First, split SelectedItems into chunks of 100
ClearCollect(ChunkedUpdates, SelectedItems);
Set(chunkSize, 100);
Set(totalChunks, RoundUp(CountRows(SelectedItems) / chunkSize, 0));
Set(currentChunk, 1);
Set(processedCount, 0);
Set(operationState, "processing");
Now the chunked loop:
ForAll(
Sequence(totalChunks),
With(
{
chunkStart: (Value - 1) * chunkSize + 1,
chunkEnd: Min(Value * chunkSize, CountRows(SelectedItems))
},
With(
{
chunkRecords: FirstN(LastN(ChunkedUpdates, CountRows(ChunkedUpdates) - chunkStart + 1), chunkSize)
},
Collect(
AllPatchResults,
Patch(
InventoryItems,
AddColumns(
chunkRecords,
"Status", "Cycle Counted",
"LastCountedDate", Now(),
"CountedBy", currentUser.Email
)
)
);;
Set(processedCount, processedCount + CountRows(chunkRecords));;
Set(currentChunk, currentChunk + 1)
)
)
)
Warning: The
ForAll-with-Sequencepattern is the closest Canvas Apps gets to a for-loop with index. The;;(double semicolons) are required to separate multiple statements in behavior formulas — single semicolon chains don't work insideForAll.
The progress bar itself can be a rectangle whose width is a percentage of the container:
// Width formula on your progress rectangle
Parent.Width * (processedCount / Max(totalCount, 1))
And a percentage label:
Text(processedCount / Max(totalCount, 1), "0%") & " — Chunk " & currentChunk & " of " & totalChunks
Reality check: Canvas Apps does update variables visible in the UI between iterations of
ForAllwhen those iterations involve async connector calls. You will see the progress bar move in real time. Test this behavior — it's not guaranteed in all contexts, but it works reliably for connector calls insideForAll.
Let's talk about a subtle bug that bites bulk operations particularly hard: you think you have all the records matching your criteria, but you actually only have the first 500 (or 2000).
The Filter function is delegable for basic comparisons in SharePoint and Dataverse. But CountRows(Filter(...)) is not delegable, and neither are many functions you might apply to your filtered results. The default row limit is 500. Even if you've raised it to 2000 in your app settings, that's still a hard cap — and for a bulk operation, silently updating only 2000 of 3500 records is a serious data integrity problem.
Never use delegable Filter for bulk operations without understanding your record count. Instead:
// Step 1: Get the count from a Flow or calculated field first
// Step 2: Use a paginated approach if needed
// Step 3: For Dataverse, use Views which handle delegation properly
For Dataverse, you can fetch all matching records by using Filter on indexed columns and relying on Dataverse's server-side delegation. Just make sure delegation is actually happening — check that there's no yellow delegation warning on your formula bar.
For SharePoint, if you need more than 2000 records, you genuinely cannot do this purely client-side. You'll need Power Automate.
// The safe pattern: always check your record count
Set(expectedCount, /*your known total from a separate source or Flow*/);
ClearCollect(SelectedItems, Filter(InventoryItems, Status = "Pending" && Location = selectedLocation));
If(
CountRows(SelectedItems) < expectedCount,
Notify("Warning: Only " & CountRows(SelectedItems) & " records loaded. Delegation limit may apply. Use the Automate button for full dataset updates.", NotificationType.Warning),
// Proceed with bulk update
Set(operationState, "processing")
)
Canvas Apps bulk operations are appropriate for datasets up to roughly 500–2000 records, depending on your data source and network conditions. Beyond that, or when you need transactional guarantees, you should offload the work to Power Automate.
The pattern is simple: collect the IDs of records to update, pass them to a Flow, let the Flow do the work server-side, and poll for completion.
// Button OnSelect - passing record IDs to a Flow
Set(operationState, "processing");
Set(flowRunID,
BulkUpdateFlow.Run(
JSON(
ShowColumns(SelectedItems, "ID", "ItemCode"),
JSONFormat.IndentFour
),
selectedLocation,
currentUser.Email
).runid
)
In your Flow, you receive the JSON array, parse it, and process each record using Dataverse or SharePoint actions — which have their own built-in retry logic and don't have the same API throttling constraints as client-side connector calls.
Back in Canvas Apps, use a Timer control to poll a status field or a Flow status collection:
// Timer OnTimerEnd (repeating every 5 seconds while operationState = "processing")
If(
operationState = "processing",
With(
{statusResult: GetFlowStatus.Run(flowRunID)},
If(
statusResult.status = "completed",
Set(operationState, "complete");
Set(processedCount, statusResult.successcount);
Collect(FailedUpdates, ParseJSON(statusResult.failures)),
statusResult.status = "failed",
Set(operationState, "error"),
// Still running - update progress from status
Set(processedCount, statusResult.progresscount)
)
)
)
This pattern gives you true server-side processing with client-side progress display, and it works for any dataset size.
Let's build something real. You'll create a bulk expense report approval screen that processes up to 500 records, shows a progress bar, and gives supervisors a clear summary of what succeeded and what failed.
Setup: Create a SharePoint list called ExpenseReports with these columns:
Title (text, use as expense description)SubmittedBy (person)Amount (number)Status (choice: Pending, Approved, Rejected)ApprovedBy (text)ApprovedDate (date)Department (text)ReportMonth (text, format: "2024-01")Populate it with at least 50 test records with Status = "Pending."
Build the screen:
1. App-level variables — put these in App.OnStart:
Set(currentUser, Office365Users.MyProfileV2());
Set(operationState, "idle");
Set(processedCount, 0);
Set(totalCount, 0);
ClearCollect(SelectedExpenses, Blank());
ClearCollect(ApprovalResults, Blank());
ClearCollect(FailedApprovals, Blank())
2. Add a Gallery for pending expenses. Set Items to:
Sort(
Filter(ExpenseReports, Status = "Pending", Department = deptDropdown.Selected.Value),
SubmittedBy.DisplayName,
SortOrder.Ascending
)
In the gallery, add a checkbox. Set its Default to:
!IsBlank(LookUp(SelectedExpenses, ID = ThisItem.ID))
Set OnCheck:
Collect(SelectedExpenses, ThisItem)
Set OnUncheck:
Remove(SelectedExpenses, LookUp(SelectedExpenses, ID = ThisItem.ID))
3. Add a "Select All Visible" button:
ClearCollect(SelectedExpenses, Filter(ExpenseReports, Status = "Pending", Department = deptDropdown.Selected.Value))
4. Add the Approve button with the complete bulk operation logic:
// Validation
If(
CountRows(SelectedExpenses) = 0,
Notify("Please select at least one expense report.", NotificationType.Warning),
// Set up state
Set(totalCount, CountRows(SelectedExpenses));;
Set(processedCount, 0);;
Set(operationState, "processing");;
ClearCollect(ApprovalResults, Blank());;
ClearCollect(FailedApprovals, Blank());;
// Build the updates table
ClearCollect(
ExpenseUpdates,
AddColumns(
SelectedExpenses,
"Status", "Approved",
"ApprovedBy", currentUser.userPrincipalName,
"ApprovedDate", Today()
)
);;
// Execute the batch patch
ClearCollect(
ApprovalResults,
Patch(ExpenseReports, ExpenseUpdates)
);;
// Separate successes from failures
ClearCollect(
FailedApprovals,
Filter(ApprovalResults, !IsBlank(Error))
);;
// Update state
Set(processedCount, CountRows(SelectedExpenses) - CountRows(FailedApprovals));;
Set(operationState, If(CountRows(FailedApprovals) > 0, "error", "complete"));;
// Clear selection
ClearCollect(SelectedExpenses, Blank())
)
5. Add the progress/status display area:
A rectangle for the progress bar container (fill it light gray), 400px wide, 20px tall.
Inside it, an overlay rectangle whose width formula is:
If(
operationState = "processing",
400, // Full width while processing — we don't have granular progress in single-patch mode
400 * (processedCount / Max(totalCount, 1))
)
Set the fill to green if complete, orange if error, blue if processing.
A status label below it:
Switch(
operationState,
"idle", "Select expenses to approve, then click Approve Selected.",
"processing", "Approving " & totalCount & " expense reports...",
"complete", "✓ Successfully approved " & processedCount & " expense reports.",
"error", "⚠ Approved " & processedCount & " of " & totalCount & ". " & CountRows(FailedApprovals) & " failed."
)
6. Add a failures gallery (Visible only when CountRows(FailedApprovals) > 0):
Set Items to FailedApprovals, and display ThisItem.Title, ThisItem.SubmittedBy.DisplayName, and ThisItem.Error.
Test it by running approvals on batches of 10, 50, and as many as your test data supports. Watch how the status changes and experiment with manually corrupting a record to trigger a failure and see it appear in the failures gallery.
// Wrong — you have no idea what happened
Patch(ExpenseReports, ExpenseUpdates);
// Right — capture everything
ClearCollect(ApprovalResults, Patch(ExpenseReports, ExpenseUpdates))
If you don't store the return value, you can't distinguish successes from failures. Always capture it.
If your records in SelectedItems came from Filter and then you used AddColumns, the record type may not exactly match your data source. The fix: make sure your AddColumns includes only columns that exist in the data source schema, and that the IDs are intact from the original records.
// Debug this by checking what's in your update collection
Browse(ExpenseUpdates) // Use a data table control to inspect this
SharePoint choice fields require you to match the exact format the connector expects. For choice fields:
// Wrong
"Status", "Approved"
// Right for SharePoint choice columns
"Status", {Value: "Approved"}
Test a single Patch on one record first if you're unsure of the correct format.
// This does NOT reliably update progressLabel between iterations
ForAll(someCollection, Set(progressCount, progressCount + 1))
Unless the ForAll body contains an async operation (like a connector call), UI updates won't render between iterations. Stick to connector calls inside ForAll for chunked processing, or use the single-batch Patch with pre/post status updates.
If you notice that your bulk operation seems to process only exactly 500 or exactly 2000 records no matter how many you select, delegation is biting you on the Filter that populates your selection. Enable ShowColumns on delegable columns only, and confirm your filter predicates are fully delegable with no yellow squiggle warning.
If your batch Patch is taking longer than expected:
CountRows(ExpenseUpdates) in a label)ShowColumns to trim your update table to only the fields actually changing)You've now got a complete mental model for bulk data operations in Canvas Apps. The core principles to take with you:
Build your update table locally first. Use AddColumns on your selected records to construct the exact table you want to Patch. This separates your transformation logic from your write operation and makes both easier to debug.
Use the table-form of Patch. A single Patch(DataSource, TableOfChanges) call is dramatically more efficient than ForAll with nested Patch. It batches API calls and gives you a single result table to inspect.
Always capture and inspect the return value. Partial failures are silent if you don't check. ClearCollect your results, then filter for error records.
Match your architecture to your data volume. Under 500 records: single batch Patch. 500–2000 records: chunked ForAll with real progress tracking. Over 2000 records: Power Automate with client-side polling.
Tell users what's happening. The difference between "the app froze" and "a clear progress indicator" is a few variable updates and a rectangle fill formula. Always invest in this.
Next steps to deepen this skill set:
Errors function for checking errors on your data source after operations — an alternative approach to inspecting Patch return valuesEnvironment variables and named formulas to centralize your batch size configuration across an entire appBulk operations done well are the difference between a Canvas App that users trust for critical business processes and one that "works in testing." You now have the tools to build the former.