Building a gallery search that works on 500 rows is easy. Building one that works correctly on 50,000 rows without silently missing records is a different challenge entirely. This lesson teaches you exactly which functions delegate to Dataverse, how to chain StartsWith and Combo Box selections into server-side queries, and how to validate your delegation posture before shipping.

You've built a gallery that works perfectly in testing — it searches through your product catalog, filters by category, shows the right records. Then you connect it to the real Dataverse table with 45,000 rows and suddenly you're looking at a yellow warning triangle in the formula bar, a delegation warning in the app checker, and worst of all: search results that silently miss records. Users start filing tickets. Your manager asks why the app can't find products that clearly exist in the database.
This is the delegation problem, and it bites almost every Canvas App builder who moves from proof-of-concept to production scale. The good news is that Dataverse is one of the most capable connectors Power Apps offers — when you know how to write formulas it can actually push to the server. The gap between "works on 500 rows" and "works on 500,000 rows" isn't usually a rewrite. It's about understanding which functions translate to server-side queries and structuring your UI controls to feed those functions cleanly.
By the end of this lesson, you'll have built a real-time filtered gallery that searches a large Dataverse table without hitting delegation limits. You'll understand why StartsWith delegates while Search often doesn't, how Combo Boxes give you multi-value filter inputs that chain naturally into delegable queries, and how to architect your filter formula so Power Apps never needs to pull the full dataset to the client.
What you'll learn:
StartsWith and Filter that pushes the query to the serverYou should already be comfortable with:
Filter and LookUp formulas in Canvas AppsIf you want a deeper theoretical foundation on delegation before applying the patterns here, the companion lesson Canvas App Delegation Deep Dive: Rewriting Non-Delegable Queries with Named Formulas, Explicit Column Selection, and Server-Side Filtering Patterns covers the internals in detail. This lesson is focused on the applied, UI-level implementation.
When Power Apps evaluates a formula like Filter(Products, Category = "Electronics"), it has two ways to get the answer: pull all rows to the client and filter locally, or translate the formula into a query the data source can execute on the server. The first approach is delegation failure. The second is delegation success.
The default row limit for client-side evaluation is 500 records (configurable up to 2,000 in app settings). If your table has 10,000 rows and you write a non-delegable filter, Power Apps silently fetches the first 2,000 rows and filters those. The remaining 8,000 rows are invisible to your app. No error. No warning to the user. Just wrong results.
Dataverse's delegation surface area is the broadest of any standard connector because Dataverse speaks OData natively, and Power Apps can translate a large subset of its formula language into OData query predicates. This means you can push complex Filter conditions, Sort operations, and column selections to the server — but only if you write them in ways Dataverse understands.
The critical practical difference between Dataverse and SharePoint or SQL Server (via the on-premises gateway, covered in Configuring Power Apps Connectors with On-Premises Data Gateway) is that Dataverse supports delegation for a richer set of string functions. StartsWith delegates. EndsWith delegates. But Search, the friendly full-text function you probably want to use, does not delegate against Dataverse tables. Neither does Contains in most scenarios. This single fact is responsible for the majority of production search failures in Canvas Apps.
Key insight: The delegation capability of a function is connector-specific.
Searchdoes not delegate to Dataverse.StartsWithdoes. Choosing the right function isn't just a preference — it determines whether your app works correctly at scale.
Before writing a single formula, internalize these function categories for Dataverse:
Fully delegable (safe for any table size):
Filter with =, <>, <, <=, >, >= on text, number, date, and choice columnsStartsWith(column, value) on text columnsEndsWith(column, value) on text columnsAnd, Or, Not as logical combinators inside FilterSort and SortByColumns on indexed columnsCountRows with a delegable filterIn operator against a fixed list (with caveats — more on this shortly)Non-delegable (dangerous at scale):
Search(table, text, col1, col2, ...) — always runs client-sideContains(text, substring) on its own — not delegable as a filter predicateLen, Left, Right, Mid inside filter conditionsLower, Upper, Trim inside filter conditionsCountIf with non-delegable predicatesFirst, Last — not themselves the issue, but combining them with non-delegable filters isWarning: The yellow delegation warning in the formula bar is your only signal that something is wrong. It will not stop the app from running, and it will not tell your users they're seeing incomplete data. Test every filter formula against a table with more than 2,000 rows before you ship.
The practical implication: if you want to search a product name field for records where the name starts with what the user typed, use StartsWith. If you want to search anywhere in the string (middle, end), you need a different strategy — either EndsWith for suffix matching, a Dataverse search table with a calculated column, or an indexed view. We'll focus on the StartsWith pattern here because it handles the majority of real search scenarios (most people search from the beginning of a name or code) and it's 100% delegation-safe.
We'll build around a realistic scenario throughout this lesson. You work at a mid-size distributor. Your customer service team needs an app to look up products during calls. The Dataverse table is called wsd_Products and has these relevant columns:
| Column | Type | Notes |
|---|---|---|
wsd_ProductName |
Text | Indexed |
wsd_SKU |
Text | Indexed |
wsd_Category |
Choice | Electronics, Hardware, Software, etc. |
wsd_Subcategory |
Lookup → wsd_Subcategories | Related table |
wsd_UnitPrice |
Currency | |
wsd_IsActive |
Boolean | |
wsd_LastModifiedOn |
DateTime |
The table has approximately 38,000 rows. The team needs to:
This is a complete, production-grade delegation challenge.
Add a Text Input control to your screen. Set its properties:
"""Search by product name..."trueThe DelayOutput property is important. When set to true, the control waits until the user pauses typing before it fires an update to any formula referencing it. Without this, every keystroke triggers a new Dataverse query. With it, you get debouncing behavior that reduces network calls dramatically.
Name this control txtProductSearch.
A Combo Box control (not a Dropdown) is the right tool for multi-select filtering. Combo Boxes allow users to pick multiple values, and their .SelectedItems property returns a table — which you can use directly in delegation-safe filter clauses.
Add a Combo Box and configure it:
Choices(wsd_Products.wsd_Category)
true["Value"]trueName it cmbCategory.
Tip: Using
Choices()against a Dataverse Choice column is always delegation-safe for populating a Combo Box. The function retrieves the static option set metadata, not rows from the table. It's a one-time metadata call, not a data query.
Add a Toggle control named togActiveOnly:
true"Active Only""All Products"This gives the team a simple way to include discontinued products when needed.
Now the critical part. Set the Items property of your Gallery to this formula:
SortByColumns(
Filter(
wsd_Products,
(IsBlank(txtProductSearch.Text) || StartsWith(wsd_ProductName, txtProductSearch.Text)),
(IsEmpty(cmbCategory.SelectedItems) || wsd_Category In cmbCategory.SelectedItems.Value),
(!togActiveOnly.Value || wsd_IsActive = true)
),
"wsd_ProductName",
SortOrder.Ascending
)
Let's break down why every clause here is delegation-safe:
Clause 1 — Text Search:
(IsBlank(txtProductSearch.Text) || StartsWith(wsd_ProductName, txtProductSearch.Text))
When the search box is empty, IsBlank returns true, and the Or short-circuits — no filter is applied on the name column. When there's text, StartsWith pushes a server-side OData predicate: startswith(wsd_ProductName, 'your text'). Both IsBlank and StartsWith delegate to Dataverse.
Clause 2 — Category Multi-Select:
(IsEmpty(cmbCategory.SelectedItems) || wsd_Category In cmbCategory.SelectedItems.Value)
When nothing is selected, IsEmpty short-circuits. When items are selected, the In operator checks whether the column value is in the list of selected values. For Dataverse Choice columns, this translates to an OData any or membership query on the server. The .Value field accessor on cmbCategory.SelectedItems gets you the text value of each selected Choice.
Clause 3 — Active Status:
(!togActiveOnly.Value || wsd_IsActive = true)
When the toggle is off, !false = true short-circuits. When it's on, wsd_IsActive = true is a simple Boolean equality filter — fully delegable.
Sorting:
SortByColumns on a text column delegates to Dataverse as long as the column is indexed. wsd_ProductName is indexed in our schema, so this pushes an OData $orderby clause.
Key insight: The
||(Or) short-circuit pattern —(IsBlank(input) || delegable_condition)— is the standard idiom for "optional filters" in delegation-safe Canvas App formulas. When the control has no value, the clause contributes nothing to the query. When it has a value, the delegable condition runs server-side.
The In operator with Dataverse needs careful attention. It delegates for Choice columns and for lookup columns when you're comparing against a fixed list of values. However, there's a documented limit: Dataverse delegation for In supports up to approximately 5,000 items in the list, which is more than any Combo Box selection would ever contain, so you're safe there.
What doesn't delegate with In: comparing against a column from another Dataverse table directly in the filter (a cross-table join in the predicate). If you need to filter on a related table's column, use a LookUp or add the related value to a local collection and filter against that. For complex multi-table join filtering, see the companion lesson Canvas App Delegation Workarounds for Complex Multi-Filter Queries.
Real apps often need cascading filters. The team wants to filter by Subcategory, but subcategories should only show the options relevant to the selected Category. This requires a dependent Combo Box.
Add a second Combo Box named cmbSubcategory. Set its Items property:
If(
IsEmpty(cmbCategory.SelectedItems),
wsd_Subcategories,
Filter(
wsd_Subcategories,
wsd_ParentCategory In cmbCategory.SelectedItems.Value
)
)
This query against the wsd_Subcategories table is also delegation-safe — it's a Filter with an In clause on a text/choice column.
Now update the Gallery's Items formula to include the subcategory clause:
SortByColumns(
Filter(
wsd_Products,
(IsBlank(txtProductSearch.Text) || StartsWith(wsd_ProductName, txtProductSearch.Text)),
(IsEmpty(cmbCategory.SelectedItems) || wsd_Category In cmbCategory.SelectedItems.Value),
(IsEmpty(cmbSubcategory.SelectedItems) || wsd_Subcategory In cmbSubcategory.SelectedItems.Value),
(!togActiveOnly.Value || wsd_IsActive = true)
),
"wsd_ProductName",
SortOrder.Ascending
)
Warning: When you add
cmbSubcategory, clear its selections whenevercmbCategorychanges. Otherwise users can end up with a category/subcategory combination that returns zero results without understanding why. Set theOnChangeproperty ofcmbCategoryto:Reset(cmbSubcategory). TheResetfunction clears a Combo Box's selected items.
By default, when Power Apps queries Dataverse through a Filter, it retrieves all columns in the table. For a table with 40+ columns, this is wasteful — you're transferring data for columns the gallery never displays.
Use ShowColumns to restrict the query to only what you need:
SortByColumns(
ShowColumns(
Filter(
wsd_Products,
(IsBlank(txtProductSearch.Text) || StartsWith(wsd_ProductName, txtProductSearch.Text)),
(IsEmpty(cmbCategory.SelectedItems) || wsd_Category In cmbCategory.SelectedItems.Value),
(IsEmpty(cmbSubcategory.SelectedItems) || wsd_Subcategory In cmbSubcategory.SelectedItems.Value),
(!togActiveOnly.Value || wsd_IsActive = true)
),
"wsd_ProductName",
"wsd_SKU",
"wsd_Category",
"wsd_UnitPrice",
"wsd_IsActive"
),
"wsd_ProductName",
SortOrder.Ascending
)
This translates to an OData $select parameter in the query, so Dataverse only returns those five columns. On a table with many wide text or file columns, this can reduce payload size by 80% and meaningfully improve gallery refresh speed.
Tip:
ShowColumnsis delegation-safe when wrapping a delegableFilteragainst Dataverse. It adds an OData$selectclause to the server query rather than selecting client-side. Always pair it with your filter for production galleries.
Users always want to see "Showing X of Y results." The naive approach breaks things immediately:
// DON'T DO THIS - forces a full client-side evaluation
"Showing " & CountRows(galProducts.AllItems) & " results"
CountRows(gallery.AllItems) only counts what's been rendered in the gallery's current page — not the full server result set. It's also non-delegable.
The correct approach uses CountRows with a delegable filter — the same filter you're using for the gallery:
"Showing " &
Text(
CountRows(
Filter(
wsd_Products,
(IsBlank(txtProductSearch.Text) || StartsWith(wsd_ProductName, txtProductSearch.Text)),
(IsEmpty(cmbCategory.SelectedItems) || wsd_Category In cmbCategory.SelectedItems.Value),
(IsEmpty(cmbSubcategory.SelectedItems) || wsd_Subcategory In cmbSubcategory.SelectedItems.Value),
(!togActiveOnly.Value || wsd_IsActive = true)
)
)
) &
" products found"
This fires a separate $count=true OData request to Dataverse and returns the accurate total, regardless of how many rows match. Yes, it's a second network call — but it's a lightweight aggregation query, not a full data retrieval. The gallery itself fetches the first page of rows; the count label fetches just the number.
To avoid firing this expensive count on every character typed, you can store the filter criteria in a Named Formula (if you're on a version that supports them) or a context variable that updates with DelayOutput from the text input.
Writing the same Filter expression twice (once for the gallery, once for the count) is fragile. If you update the filter logic, you have to update both places and risk them getting out of sync.
Named Formulas, defined in the App's Formulas property, let you define a reusable expression:
// In App.Formulas
ProductFilter = Filter(
wsd_Products,
(IsBlank(txtProductSearch.Text) || StartsWith(wsd_ProductName, txtProductSearch.Text)),
(IsEmpty(cmbCategory.SelectedItems) || wsd_Category In cmbCategory.SelectedItems.Value),
(IsEmpty(cmbSubcategory.SelectedItems) || wsd_Subcategory In cmbSubcategory.SelectedItems.Value),
(!togActiveOnly.Value || wsd_IsActive = true)
);
Now your gallery Items becomes:
SortByColumns(
ShowColumns(
ProductFilter,
"wsd_ProductName", "wsd_SKU", "wsd_Category", "wsd_UnitPrice", "wsd_IsActive"
),
"wsd_ProductName",
SortOrder.Ascending
)
And your count label Text becomes:
"Showing " & Text(CountRows(ProductFilter)) & " products found"
Named Formulas are evaluated lazily and memoized — Power Apps doesn't re-execute them more often than necessary. They also keep control-level properties clean and readable. This is an underused feature that makes complex filtering apps dramatically more maintainable. You can learn more about this pattern in the article on Canvas App State Management at Scale.
Writing the formula is only half the job. You need to verify it's actually delegating.
In Power Apps Studio, select the gallery and look at the Items formula. If there's a yellow/orange triangle with an underline on any function, hover over it. The tooltip will tell you which function is non-delegable and why.
Open the App Checker (the shield icon in the top toolbar). Navigate to the Formulas tab. Any delegation issues appear here with the specific formula location and the function causing the problem.
The Monitor tool is the gold standard for verifying delegation. With your app running in debug mode, open Monitor (from the toolbar) and interact with the search box. Watch for Network events — you should see requests to your Dataverse endpoint that include:
$filter=startswith(wsd_ProductName,'your text') — confirms text search is server-side$select=wsd_ProductName,wsd_SKU,... — confirms column selection is server-side$orderby=wsd_ProductName asc — confirms sorting is server-sideIf you see a request that fetches all rows without a $filter clause, followed by a lot of rows in the response, your filter is running client-side. Time to debug. For a complete guide to using this tool, see Debugging Canvas Apps: Using the Power Apps Monitor Tool and Formula Errors to Fix Issues Fast.
Tip: Temporarily set your app's Data row limit (in Settings → Advanced Settings → Data row limit) to 500 and test with a table that has more than 500 matching records. If your search results look incomplete — missing records you know exist — you have a delegation failure. This is the cheapest way to catch the problem before production.
The customer service team also needs to search by SKU — a separate column. You might want to support "search term matches the start of either ProductName OR SKU." Here's how to extend the formula safely:
(IsBlank(txtProductSearch.Text) ||
StartsWith(wsd_ProductName, txtProductSearch.Text) ||
StartsWith(wsd_SKU, txtProductSearch.Text))
Both StartsWith calls delegate independently. Dataverse translates this to:
startswith(wsd_ProductName,'term') or startswith(wsd_SKU,'term')
This is a perfectly valid OData predicate, and Dataverse handles it server-side. Your filter formula grows, but your delegation safety holds.
What you cannot do safely is something like:
// DON'T DO THIS - Contains does not delegate
Contains(wsd_ProductName, txtProductSearch.Text)
If users need true substring search (finding "Widget" inside "Blue Widget Pro"), you have architectural options:
For most customer service lookup scenarios, prefix search is entirely sufficient and is what users naturally do.
Here's a structured exercise to apply everything in this lesson.
Setup:
wsd_Products with columns as described in the scenario (you can use a sample import of 3,000+ rows — enough to trigger delegation failures if your formulas are wrong)Step 1: Layout
Build a header section with three controls side by side:
txtProductSearch (Text Input, width ~400)cmbCategory (Combo Box, width ~250, items from Choices(wsd_Products.wsd_Category), SelectMultiple = true)togActiveOnly (Toggle, default true)Add a cmbSubcategory below, full width.
Add a Label below the controls for the result count.
Step 2: App.Formulas
Define ProductFilter as shown in the Named Formulas section above.
Step 3: Gallery
Add a vertical gallery below the controls. Set Items to the SortByColumns(ShowColumns(ProductFilter,...)) formula. Design the gallery template to show wsd_ProductName, wsd_SKU, wsd_Category.Value, and wsd_UnitPrice formatted as currency.
Step 4: Count Label
Set the count label's Text to "Showing " & Text(CountRows(ProductFilter)) & " products found".
Step 5: Cascade Reset
Set cmbCategory.OnChange to Reset(cmbSubcategory).
Step 6: Validate
Run the app in Monitor mode. Type "pr" in the search box. Verify the Monitor shows a request with $filter=startswith(wsd_ProductName,'pr') or startswith(wsd_SKU,'pr'). Select a category in the Combo Box. Verify the OData request gains an additional filter clause.
Stretch goal: Add a date range filter using two Date Picker controls (dtpFrom, dtpTo) and extend ProductFilter with:
(IsBlank(dtpFrom.SelectedDate) || wsd_LastModifiedOn >= dtpFrom.SelectedDate) &&
(IsBlank(dtpTo.SelectedDate) || wsd_LastModifiedOn <= dtpTo.SelectedDate)
Both >= and <= on DateTime columns delegate to Dataverse. Validate in Monitor that the OData request includes the date predicates.
Symptom: Yellow delegation warning on the gallery, incomplete results for tables over 2,000 rows.
Fix: Replace Search(table, textInput.Text, "Column1", "Column2") with the StartsWith pattern shown in this lesson. If you need multi-column search, chain multiple StartsWith calls with ||.
Symptom: Delegation warning specifically on a column reference inside Lower(), Trim(), or similar.
Problematic formula:
Filter(wsd_Products, Lower(wsd_ProductName) = Lower(txtSearch.Text))
Fix: Remove the string transformation. Dataverse text comparisons in Filter are case-insensitive by default. You don't need Lower() — and adding it breaks delegation.
Symptom: Using In to check membership against a local collection (created with ClearCollect) inside the main Filter.
Problematic formula:
Filter(wsd_Products, wsd_ProductID In colSelectedIDs) // colSelectedIDs is a Collection
Fix: If the list of valid IDs comes from a Dataverse table, filter against that table directly using a delegable predicate. If it must come from a collection (user selections over session), understand you're running that clause client-side and plan your data volume accordingly. See the article on Power Apps Collections and Local Data Management for patterns that manage this safely.
Symptom: Delegation warning on SortByColumns, results look sorted in some runs but not others.
Fix: Go to your Dataverse table in the maker portal. Verify the column you're sorting by has Enable column security or check if it's listed as indexed. For text columns you sort frequently, enable the index in the table's column settings. Sorting on non-indexed columns may work but can time out on large tables and may not delegate reliably.
Symptom: Count label shows "20" when there are 4,500 matching records (because the gallery renders 20 items per page).
Fix: Use CountRows(ProductFilter) or the same delegable Filter expression, as shown earlier. This makes a separate aggregation call to Dataverse rather than counting rendered gallery rows.
Symptom: Every character typed fires a Dataverse query, causing visible flickering and excessive API calls.
Fix: Set DelayOutput to true on your Text Input control. For additional control over timing, you can also use a Submit button pattern where the filter only refreshes when the user clicks "Search" — useful for very large tables where even debounced live search is too slow.
Note: Power Apps has a service protection limit on Dataverse API calls. High-frequency live search with many simultaneous users can hit throttling limits. If you're building for more than ~50 concurrent users,
DelayOutput = trueis mandatory, and consider a Submit button pattern as a fallback.
The pattern in this lesson — StartsWith + Filter with optional clauses + ShowColumns + Named Formulas — is the right default approach for Dataverse tables of any size when you need:
It handles tables with hundreds of thousands of rows as efficiently as tables with hundreds, because all the heavy lifting happens on the Dataverse server.
When you might need a different approach:
You've built a production-ready, delegation-safe search interface for a large Dataverse table. The core lessons:
StartsWith delegates, Search doesn't. For prefix-based text search against Dataverse, always use StartsWith inside a Filter.IsBlank(input) || delegable_condition) is the standard idiom for multi-control filter interfaces where any control might be empty.SelectMultiple = true give you multi-value filter inputs whose .SelectedItems.Value table works cleanly with the In operator in delegable filters.ShowColumns adds an OData $select clause that dramatically reduces payload size for wide tables.App.Formulas eliminate duplicate filter expressions and make the app maintainable.Where to go next:
If your filtering needs are more complex than StartsWith can handle — cross-table predicates, substring matching, or multi-step enrichment — work through Canvas App Delegation Workarounds for Complex Multi-Filter Queries.
For making the gallery itself performant at the rendering level — virtual scrolling, template optimization, and lazy loading — see Power Apps Controls: Galleries, Forms, and Data Tables — Advanced Architecture and Performance.
And if you want to lock down what data different users can search and see at the data layer level (rather than just filtering in the UI), Power Apps Security: Roles, Sharing, and Data Permissions covers Dataverse row-level security that works in concert with the query patterns you've just learned.