Dataverse choice columns are simple dropdowns on the surface—but the decisions you make about global vs. local option sets, multi-select behavior, and dependency filtering determine whether your data model stays coherent as your app scales. This lesson teaches the internals, the patterns, and the pitfalls that experts need to know.

Picture this: your organization builds a model-driven app to manage service requests. Early on, someone creates a "Category" choice column directly on the table—quick, easy, done. Six months later, you're adding a second table for escalations. Then a third for knowledge articles. Every single one needs the same category list, so developers create it three separate times. Now your category list has drifted. "Networking" in one table, "Network" in another, "IT Networking" in a third. Reports won't group correctly. Power Automate flows break when values don't match. Users file tickets because the dropdown options look different depending on which form they're on. This is the global option set problem, and it's one of the most common data quality issues in mature Dataverse environments.
Choice columns (formerly called Option Sets) are deceptively simple on the surface: a dropdown with a fixed list of values. But beneath that surface sits a rich system with meaningful architectural decisions. Do you use a global choice or a local one? Do you use a single-select or multi-select column? How do you filter the available options based on what the user has already selected in another field? Get these decisions right at design time and your data model stays coherent as the app grows. Get them wrong and you spend your next project cycle untangling mismatched integers stored in your database that happen to look like different words in different tables.
By the end of this lesson you'll have a genuine, expert-level understanding of how Dataverse stores and manages choice values, how to create and reuse global choices across multiple tables, how multi-select choice columns behave differently from their single-select cousins (including the storage implications), and how to implement dependency filtering between two related choice columns in a model-driven form—both using business rules and JavaScript. You'll also understand the edge cases that trip up experienced makers and the migration paths when your initial design needs to evolve.
What you'll learn:
Before you touch a form or a column configuration panel, you need to understand what's happening underneath. Dataverse does not store "New", "Active", or "Networking" as text strings in your database rows. It stores them as 32-bit integers.
Each option in a choice column has two parts: a numeric value and a display label. The display label is what users see. The integer is what gets written to the database, sent through the API, and matched in Power Automate conditions. When you create a new option set value, Dataverse assigns an integer automatically based on your publisher prefix. If your publisher prefix is 10000, your values might be 10000000, 10000001, 10000002, and so on. That prefix is configurable per solution publisher, and it matters enormously for ISV scenarios or environments where multiple managed solutions co-exist.
Key insight
This integer-storage model means that if you rename a choice option—say, changing "Networking" to "Network Infrastructure"—existing database records are not affected. The integer value in storage stays the same; only the display label changes. This is good news for label corrections, but it means you can never re-use an integer value for a different semantic meaning without corrupting historical data.
The integer-to-label mapping is stored in the OptionSetMetadata system tables in Dataverse (and in the underlying SQL metadata layer). When you query a choice column through the Web API, you get back the integer. To get the label, you either use $select with @OData.Community.Display.V1.FormattedValue annotations, or you query the metadata endpoint separately. This distinction trips up developers who expect to filter API results by the displayed text.
For Power Automate, when you reference a choice column in a condition, the value available in the dynamic content panel is typically the label. But the actual value sent through the Dataverse connector is the integer. If you're ever debugging a Flow condition that inexplicably never matches, check whether you're comparing a label to an integer, or an integer to a label. The dynamic content sometimes surfaces formatted labels and sometimes raw integers depending on the trigger and action type.
For multi-select choice columns, the storage model is different again. Dataverse stores the selected values as a comma-delimited string of integers at the database level—for example, 10000000,10000002,10000005. The OData API represents these as a collection, but the underlying storage is that delimited string. This has meaningful implications: you cannot use standard eq equality filters on multi-select columns through OData. Instead, you must use contains or the ContainValues operator.
Warning
Never design an integration that expects to eq-filter a multi-select column against a single integer value using standard OData syntax. Use the Dataverse Web API ContainValues operator: ?$filter=Microsoft.Dynamics.CRM.ContainValues(PropertyName='cr4bd_categories',PropertyValues=['10000000','10000002']). This is a non-standard OData extension and is specific to Dataverse.
When you add a choice column to a Dataverse table, you face an immediate fork in the road: create the list of options locally (tied only to this column on this table) or draw from a global choice (a named, reusable list that can be shared across tables, columns, and solutions).
This decision has architectural weight. Let's walk through both paths.
A local choice is defined inline when you create the column. The option values exist only in the context of that column on that table. If you need the same list on a second table, you create it again. If you need to update all instances—say, adding a new category—you must update each column separately. Local choices are appropriate in one specific scenario: when the option list is semantically unique to that table and will never be needed elsewhere. A "Record Quality Rating" on an internal audit table might legitimately be local if no other table will ever carry that same concept.
In practice, most choice columns that represent shared business vocabulary—status types, product categories, geographic regions, priority levels, document types—should be global from day one. The cost of making something global when it could have been local is negligible. The cost of converting ten local choices to global choices after the fact is genuinely painful.
A global choice is a named metadata component in Dataverse. It lives independently of any table or column. You can create it through Power Apps > Data > Choices in the left navigation (in the classic interface) or through Tables > [Table] > Columns > New Column > Data Type: Choice > Use existing global choice in the modern make.powerapps.com interface. The global choice has its own display name, schema name, and description—it's a first-class component that can be included in a solution and deployed independently.
When multiple columns reference the same global choice, they all show the same option list and the same integer values. Updating the global choice—adding an option, renaming a label—instantly affects every column that references it across every table. This is both the power and the risk.
Warning
If you add an option to a global choice that's used in a managed solution you've distributed to downstream environments, those environments must import your updated solution before they'll see the new option. If a Power Automate flow or plugin in the upstream environment references the new integer value before the downstream environment has the updated metadata, you'll get runtime errors. Plan your global choice updates like schema migrations: test in lower environments first.
The schema name of a global choice follows the pattern publisher_prefix_displayname and is set at creation time. You cannot rename the schema name after the choice is created without breaking references. Choose schema names deliberately. In designing a Dataverse data model, schema name discipline pays dividends across the lifecycle of a solution.
Navigate to make.powerapps.com, select your solution, and choose New > More > Choice from the solution component panel. You'll provide:
Then you add your option items. Each item gets a label and Dataverse auto-assigns the integer value based on your publisher's option value prefix. You can manually set the integer value—useful when you're migrating from an existing system with known integer codes—but be aware that manually set values must still be unique within the option set.
Once created, the global choice appears in the choice picker whenever someone adds a new choice column to any table in that solution context. This is the reuse story.
Tip
Always create global choices inside a solution, not in the default solution. If you create them in the default solution or outside any solution context, they become unmanaged components that won't travel cleanly with your app. In managed solution deployments, unmanaged global choices create upgrade headaches. Read more about solution management in solutions and solution layering.
Once you have a global choice (or you've decided to use a local one), adding the column to a table is straightforward—but there are configuration options that many makers overlook.
Navigate to your table in the solution, choose Columns > New Column, set the data type to Choice, and then decide whether to use an existing global choice or create a new local one. Beyond the basics, pay attention to:
Default value: You can specify a default option. This pre-populates the field when a new record is created. Use defaults deliberately. If your service request category defaults to "General" and most users just leave it, your category distribution data is meaningless. Sometimes it's better to leave the field empty and require an explicit selection—which you can enforce with a business rule that checks the field is not empty on save.
Required: Setting the column as required at the column level enforces the constraint across all apps and integrations, including the API. Setting it required only on the form is a form-layer enforcement that can be bypassed by API calls. If a non-null value is genuinely a business rule (not just a UI preference), enforce it at the column level.
Searchable: Choice columns are searchable by default in Quick Find views. The search matches against option labels, not integers. This usually works as expected, but be aware that relevance search (Dataverse search) and Quick Find search work differently. Configuring Dataverse search covers how choice fields factor into search indexing.
IME Mode: Input Method Editor mode—leave this at "auto" unless you're building apps targeting East Asian language input and know what you're doing.
A multi-select choice column (sometimes called a "multiselect picklist" or "polymorphic option set" in older documentation) lets users select multiple values from the same list simultaneously. A service ticket might belong to multiple categories. A contact might speak multiple languages. A job listing might require multiple certifications. These are legitimate multi-value scenarios that multi-select choice columns handle elegantly at the metadata level.
In the column configuration panel, change the data type from Choice to Choices (note the plural). The configuration is otherwise identical—you pick a global choice or create a local list. The resulting form control renders as a checkbox-style picker or a multi-select dropdown depending on the form control configuration and the Power Apps version.
The underlying storage, as mentioned earlier, is a comma-delimited integer string. This has several downstream effects you should plan around:
API querying: As noted earlier, use ContainValues for filtering. The reverse, DoesNotContainValues, is also available.
Power Automate: When you read a multi-select column in a flow, the dynamic content surfaces it as an array. If you're in a condition checking "does this record's Categories contain Networking?", you need to use the contains() expression function against the array, not a direct equality comparison.
Views: Multi-select choice columns can appear in views, but the column renders all selected values as a comma-separated label string. Sorting on a multi-select column is technically possible but semantically dubious—what does "sort by Categories" mean when a record has three? Use views with multi-select columns primarily for display, not for sorting or grouping logic.
Business rules: As of the current platform version, business rules have limited support for multi-select columns. You can check whether a multi-select field contains a value using the "Contains" operator in business rule conditions, but the condition palette is more limited than for single-select columns. Complex multi-select logic typically requires JavaScript.
Note
Multi-select columns cannot be used as lookup filters, relationship mappings, or connection references. They're purely for capturing multi-value categorical data at the field level. If you find yourself wanting to relate records based on multi-select values, consider whether what you actually need is a many-to-many relationship between tables instead.
It's tempting to reach for multi-select whenever you see "multiple values." Resist the impulse and ask: will you need to query, report on, or aggregate by individual values in this list?
If you need to filter "all service requests where Category = Networking," multi-select works fine with ContainValues. But if you need to count how many records have each category—the kind of aggregate you'd build a chart on—you're in trouble. Charts in model-driven apps don't support multi-select columns natively. Power BI can handle it, but it requires unpivoting the comma-delimited column, which is a transformation step with performance cost.
For reporting-heavy scenarios, a many-to-many relationship through an intersect table (even a simple one) gives you far better aggregate query performance and native chart support. See configuring Dataverse many-to-many relationships for the patterns that make this work cleanly.
Dependency filtering is where choice columns graduate from simple dropdowns to intelligent, context-aware form elements. The concept: the options available in Choice Column B change dynamically based on what the user selected in Choice Column A.
Classic example: you have a "Region" choice (North America, Europe, APAC) and a "Country" choice. When the user selects "Europe," the Country dropdown should show only European countries—not the full global list. Another example: a "Department" choice and a "Team" choice, where available teams depend on the department.
Dataverse has a built-in dependency filtering mechanism for choice columns. It's less well-known than it should be. Let's start there before going to JavaScript.
Dataverse supports a native parent-child relationship between two choice columns on the same table. This is called dependent option sets or option set filtering. Here's how it works conceptually: you designate one option set as the "parent" and another as the "child," and you configure which child values are associated with which parent values. The form then automatically hides child values that don't belong to the selected parent.
To set this up:
Hardware, Software, Facilities.Laptop, Desktop, Server, Operating System, Application, HVAC, Electrical, etc.Warning
The native dependent option set feature requires using the classic solution explorer for configuration. The modern make.powerapps.com interface does not surface the parent-child option set configuration UI as of current platform versions. This is a known gap that may be addressed in future releases, but for now, budget time for navigating the classic experience.
The dependency configuration is stored as form metadata (in the FormXML), not purely in the option set metadata. This means you can have two forms on the same table where one shows filtered options and another doesn't. The dependency is per-form, per-column combination.
For simpler filtering scenarios—especially where you want to show/hide entire field sections or set recommended values—business rules offer a no-code path.
Business rules can:
What business rules cannot do natively is filter the available options within a choice column down to a subset of values. For that, you need either the native dependency feature described above or JavaScript.
A common pattern that works well with business rules alone: instead of one "Region" column and one "Country" column with dependency filtering, use one "Region" column and multiple country-specific hidden fields that become visible only when the corresponding region is selected. This is architecturally messier (more columns, more form complexity) but it's fully declarative and works without code. You'd use this approach in apps where you have a strict no-code policy. For most expert-level scenarios, JavaScript is the right tool.
JavaScript on model-driven forms gives you complete control over the option set control through the formContext.getControl() and getAttribute() APIs. The key method for filtering choice options is removeOption() and addOption() on the option set control.
Here's the pattern. You have two choice columns: cr4bd_servicearea (parent) and cr4bd_subcategory (child). When the service area changes, you want to repopulate the subcategory dropdown with only the relevant options.
The standard approach is to:
// Service Request Form - Dependency Filtering
// Handles: cr4bd_servicearea -> cr4bd_subcategory dependency
// Full option map: parent integer value -> array of child option objects
// These integers correspond to your actual publisher-prefixed values
var SubcategoryMap = {
// Hardware (10000000)
10000000: [
{ Value: 10000010, Label: "Laptop" },
{ Value: 10000011, Label: "Desktop" },
{ Value: 10000012, Label: "Server" },
{ Value: 10000013, Label: "Peripheral" }
],
// Software (10000001)
10000001: [
{ Value: 10000020, Label: "Operating System" },
{ Value: 10000021, Label: "Business Application" },
{ Value: 10000022, Label: "Custom Development" }
],
// Facilities (10000002)
10000002: [
{ Value: 10000030, Label: "HVAC" },
{ Value: 10000031, Label: "Electrical" },
{ Value: 10000032, Label: "Plumbing" },
{ Value: 10000033, Label: "Security Access" }
]
};
function onServiceAreaChange(executionContext) {
var formContext = executionContext.getFormContext();
filterSubcategoryOptions(formContext);
}
function onFormLoad(executionContext) {
var formContext = executionContext.getFormContext();
// Register the onChange handler for the parent field
formContext.getAttribute("cr4bd_servicearea").addOnChange(onServiceAreaChange);
// Filter on load in case a value is already set (edit scenario)
filterSubcategoryOptions(formContext);
}
function filterSubcategoryOptions(formContext) {
var serviceAreaAttribute = formContext.getAttribute("cr4bd_servicearea");
var subcategoryControl = formContext.getControl("cr4bd_subcategory");
var subcategoryAttribute = formContext.getAttribute("cr4bd_subcategory");
if (!serviceAreaAttribute || !subcategoryControl || !subcategoryAttribute) {
return; // Guard: fields may not be on this form
}
var serviceAreaValue = serviceAreaAttribute.getValue();
// Clear the current subcategory value if it won't be valid after filtering
var currentSubcategoryValue = subcategoryAttribute.getValue();
// Remove all existing options from the control
// We must iterate and remove rather than calling clearOptions (not in the API)
var allOptions = subcategoryControl.getAttribute().getOptions();
allOptions.forEach(function(option) {
subcategoryControl.removeOption(option.value);
});
if (serviceAreaValue === null) {
// No parent selected: leave child empty and blank
subcategoryAttribute.setValue(null);
return;
}
// Add back only the options for the selected parent
var relevantOptions = SubcategoryMap[serviceAreaValue];
if (!relevantOptions) {
subcategoryAttribute.setValue(null);
return;
}
relevantOptions.forEach(function(option) {
subcategoryControl.addOption({ value: option.Value, text: option.Label });
});
// Validate the current subcategory value: if it's no longer in the valid set, clear it
if (currentSubcategoryValue !== null) {
var isCurrentValueValid = relevantOptions.some(function(opt) {
return opt.Value === currentSubcategoryValue;
});
if (!isCurrentValueValid) {
subcategoryAttribute.setValue(null);
}
}
}
Several things to notice in this implementation:
The option map hardcodes integer values. This is the right approach. Do not try to look up option integers dynamically from the form context during the filtering operation—it's slow and error-prone. Instead, document the mapping in your solution design, hardcode it in the script, and version-control it. When option values change, you update the script.
The getAttribute().getOptions() call on the attribute returns all options that exist in the column metadata. The getControl().removeOption() call removes options only from the control's display—it does not modify the underlying metadata. This is safe: the underlying data model remains intact.
You must guard against null attribute references. Model-driven forms don't guarantee that every column is on every form. The guard clauses (if (!serviceAreaAttribute ...)) prevent JavaScript errors when this script runs on a form where one of the columns isn't present.
Register this in the form's OnLoad event via the form editor's "Event Handlers" panel, pointing to onFormLoad. Do not register onServiceAreaChange separately—onFormLoad adds the event handler programmatically, which is cleaner and less prone to misconfiguration.
Tip
When deploying JavaScript to model-driven apps, always store the web resource inside your solution, use the publisher prefix in the web resource name (e.g., cr4bd_/js/ServiceRequestForm.js), and increment the version number in the solution when you update the file. Model-driven apps aggressively cache web resources, and version bumping is the most reliable way to force cache invalidation in all environments.
Dependency filtering has different behavior in create vs. edit mode, and you must handle both. In create mode, the parent field starts empty, so the child should also start empty with no options. In edit mode, both fields might already have values from a previous session, and the form loads with those values—your onFormLoad function must re-filter the child's options to match the current parent value and validate that the child's current value is still valid.
The code above handles this. The filterSubcategoryOptions function runs on load and checks whether the current subcategory value is in the filtered set. If not, it clears the subcategory. You might also consider showing a notification to the user: "Your previous subcategory selection is no longer valid for the updated service area. Please re-select." You can do this with formContext.ui.setFormNotification().
Note
In cases where records are updated programmatically (through the API, a Flow, or a plugin), the dependency filtering JavaScript obviously doesn't run—it only runs in the browser. If your business logic requires that subcategory values always be consistent with their parent service area, you should enforce that rule with a server-side plugin or real-time workflow, not just with client-side JavaScript. Client-side filtering is a UX guardrail, not a data integrity constraint.
Adding a new option to a global choice is low-risk: existing records are unaffected, new records can use the new option immediately. The sequence: update the global choice in the solution, publish customizations, test. If you're managing a managed solution, increment the version and push to downstream environments.
Deprecating (removing) an option is far riskier. If any records in any table reference that option's integer value, removing the option from the choice metadata doesn't null out those records—the integer remains in the database. But the display label no longer exists in metadata, so the field renders blank or shows the raw integer (depending on the client). This is a silent data quality failure.
The correct deprecation sequence:
When two separately developed solutions both define global choices with overlapping integer values—common in ISV + customer solution combinations—you get option value conflicts. This is why the publisher prefix on integer values matters. If your publisher prefix is set correctly, your option integers are in the 1[prefix] range and won't collide with Microsoft-reserved ranges or other publishers.
If you're consuming someone else's global choice in your solution (referencing their managed global choice rather than creating your own), you depend on their update cadence. If they deprecate a value you're filtering on in your JavaScript, your form behavior will break silently. Prefer to own your global choices when you control the data model.
When you export a solution as managed, global choices can be locked via managed properties so that installers cannot add, remove, or modify option values. This is critical for ISV scenarios where your option integer values are part of your application contract—your plugin code, Flow conditions, and Power BI datasets all reference specific integers, and you can't afford those values to change in the field.
To configure: in the solution, select the global choice component, choose Managed Properties, and configure Allow customization to false. When the managed solution is installed, the global choice appears as read-only in the destination environment. The managed properties model is discussed in depth in the context of solution component locking.
Choice columns with fewer than 50 options render instantly and need no optimization. Once you get into the 50-200 option range (which happens with things like country lists, product SKUs, or complex classification taxonomies), performance starts to matter.
For large option sets, consider:
forEach chains), the overhead accumulates. Pre-build your lookup structures at form load time rather than re-querying the attribute options on every change event.Most of the form-level configuration for choice columns (dependency relationships, default values, display labels) is set through the form designer UI. But if you need to modify dependency configuration across many fields, or troubleshoot a filtering issue, it's worth knowing that the underlying form definition is XML stored in the FormXML field of the SystemForm entity.
You can export a form's XML through the classic solution explorer (open the form, navigate to Form Properties, and there's an export option). The dependency information for option set filtering lives in the <filter> nodes inside the column's <cell> element in the FormXML. This is the escape hatch when the UI isn't surfacing what you need—and for complex forms, it's often faster to directly edit the XML than to click through the designer.
Let's put all of this together with a realistic scenario. You're building a model-driven app for an IT service desk. You need:
Service Area choice column and a Subcategory choice columnEscalation tableHardware (your prefix value, e.g., 10000000), Software (10000001), Facilities (10000002), Network (10000003).10000010-10000019 for hardware subcategories, 10000020-10000029 for software, etc.cr4bd_/js/ServiceRequestForm.js with the dependency filtering code shown earlier (updating the integer values to match your actual publisher prefix values).onFormLoad. Check the "Pass execution context as first parameter" checkbox.This ensures Subcategory is required only when a Service Area is selected—forcing users to pick both fields together.
Mistake: Filtering options but forgetting the edit scenario
Your onFormLoad only calls filterSubcategoryOptions but doesn't re-validate the currently stored value. On existing records, the user opens the form, sees an apparently correct subcategory, and doesn't realize it's now showing options inconsistent with the stored parent value (because the metadata rendering still shows the label). Fix: always re-validate the current child value against the filtered set on form load.
Mistake: Using the label string in JavaScript comparisons instead of the integer
serviceAreaAttribute.getValue() returns the integer. serviceAreaAttribute.getText() returns the label string. Many scripts fail because the developer writes if (value === "Hardware") instead of if (value === 10000000). Fail fast: add a console.log of the raw value during development to confirm what type you're getting.
Mistake: Creating global choices outside of a solution If you work in the default solution or create choices ad-hoc, they don't travel with your solution export. Always work within a properly scoped solution with a publisher. See solutions and solution layering for the full picture.
Mistake: Using multi-select when you need reporting aggregation You add a multi-select column for product lines, then try to build a chart showing request volume by product line. Model-driven app charts don't support multi-select columns as chart axes. You're now blocked on reporting. Evaluate reporting requirements before choosing multi-select.
Mistake: Removing a choice option without migrating existing records Records with the removed integer value now display blank. Users think the field is empty. Your data is actually intact—the integer is there—but it's invisible. Run a FetchXML query for records with that integer value before removing the option, and always migrate first.
Troubleshooting: Dependency filtering stops working after a solution update If you update your global choice (add/rename options) and push a solution update, your JavaScript option map may now reference stale integer values. The solution update doesn't update your web resource automatically. You must update the JavaScript web resource separately, version-bump it, and re-publish. Build this into your deployment checklist.
Troubleshooting: addOption() isn't adding options to the dropdown
Confirm that the option value you're passing in addOption() actually exists in the column's underlying global choice metadata. The addOption() API can only restore options that exist in the metadata—it cannot add options that weren't part of the original choice definition. If you're trying to add a brand-new runtime option, you need to first add it to the global choice at the metadata level.
Choice columns in Dataverse are simple in appearance and rich in depth. The decisions you make when you first define them—global vs. local, single vs. multi-select, with or without dependency filtering—ripple through your entire data model and the integrations built on top of it.
To recap the key principles:
Where should you go next? If you're building complex forms that combine choice columns with conditional visibility, subgrids, and quick views, the natural next topic is designing model-driven forms at a deeper level. If you want to enforce server-side data integrity for your choice dependencies—catching API updates that bypass the form—explore configuring Dataverse table event plugins and real-time workflows. And if you're building an ISV solution where your global choices need to be locked down against modification in downstream environments, the managed properties and solution component locking article covers everything you need.