Dataverse Developer Reference
A working reference for Microsoft Dataverse — the relational, secure data platform under Power Apps, Power Automate, and Dynamics 365. Tables and column types, relationships, the OData v4 Web API, OData query options, FetchXML, the plug-in event pipeline and SDK, and the security-role model (privileges, access levels, business units, teams).
Fundamentals
Dataverse is a managed, relational data platform — tables, typed columns, relationships, keys, rich metadata, and a role-based security model, all served over a uniform OData v4 Web API and a .NET SDK. It is the native store for model-driven apps and Dynamics 365, and the most scalable source for canvas apps and cloud flows.
- Tables hold rows (records) made of typed columns. Every row has a GUID primary key (e.g.
accountid) and a primary name column. - Table types: *Standard* (normal), *Activity* (time-bound, e.g. tasks/emails), *Virtual* (data sourced from an external system at runtime), and *Elastic* (NoSQL-backed, for very high volume / variable schema).
- Ownership is chosen at table creation and is permanent: *User/Team-owned* rows participate in the full access-level hierarchy; *Organization-owned* rows are on/off only.
- Alternate keys let you address a row by a business value (e.g. an order number) instead of its GUID — essential for upsert and integration.
| Modern term | Classic term | Notes |
|---|---|---|
| Table | Entity | Schema/metadata still calls it EntityMetadata |
| Row | Record | Identified by a GUID primary key |
| Column | Field / Attribute | AttributeMetadata in the SDK |
| Choice | Option set | Local (one column) or global (reusable) |
| Choices | Multi-select option set | Stored as a comma-separated set of values |
| Yes/No | Two options / Boolean | A two-option choice under the hood |
| Lookup | Lookup / EntityReference | A foreign key to another table |
| Relationship | Relationship | 1:N, N:1, or N:N |
| Dataverse | Common Data Service (CDS) / XRM | Same platform, renamed in 2020 |
Three names for every object
Each table and column has a display name (for humans, localizable), a schema name (PascalCase, carries the publisher prefix, e.g. contoso_ApprovalStatus), and a logical name (the lowercase schema name, e.g. contoso_approvalstatus) — the Web API and FetchXML use the logical name. The publisher customization prefix is baked in forever, so pick it before you create the first table.
Column data types
| Column type | Web API JSON | Notes |
|---|---|---|
| Single line of text | string | Formats: email, URL, phone, ticker, text |
| Multiple lines of text | string | Long text; memo |
| Choice (option set) | number (Edm.Int32) | Integer value; label via FormattedValue annotation |
| Choices (multi-select) | string of numbers | Comma-separated option values |
| Yes/No (Boolean) | boolean | A two-option choice |
| Whole Number | number | Edm.Int32; also duration/timezone/language formats |
| Decimal | number | Exact; up to 10 dp — use for precise values |
| Floating point | number | Approximate — avoid for money |
| Currency (Money) | number | Adds a _base companion + exchange rate |
| Date and time | string (ISO 8601) | Behavior: User Local / Date Only / Time-Zone Independent |
| Lookup | GUID via @odata.bind | Read back as _name_value with annotations |
| Customer | polymorphic lookup | Points to account OR contact |
| Owner | polymorphic lookup | Points to systemuser OR team |
| Unique Identifier | string (GUID) | Read-only GUID column |
| Autonumber | string | Server-generated sequence (e.g. INV-0001) |
| File / Image | binary | Streamed via dedicated endpoints |
| Formula / Calculated | varies | Computed on read; not writable |
| Rollup | number/money | Aggregated async by a system job |
DateTime behavior is a one-way decision
A date column’s behavior — *User Local* (stored UTC, shown in the user’s zone), *Date Only*, or *Time-Zone Independent* — is set at creation and mostly cannot be changed later. Use *Date Only* for birthdays and *Time-Zone Independent* for values like a contract date that must not shift across zones.
Relationships
Relationships are first-class metadata, not just foreign keys. A 1:N relationship adds a lookup column on the “many” (child) table; an N:N relationship creates a hidden intersect table. In the Web API you traverse them as navigation properties with $expand.
| Type | What it is | Example |
|---|---|---|
| 1:N (one-to-many) | A parent row has many children | Account → Contacts |
| N:1 (many-to-one) | The lookup column on the child | Contact → Account (parentcustomerid) |
| N:N (many-to-many) | Two-way, via an intersect table | Course ↔ Student |
| Customer (polymorphic) | Lookup to account OR contact | Case.customerid |
| Owner (polymorphic) | Lookup to systemuser OR team | ownerid on owned tables |
| Regarding (polymorphic) | Activity → many possible tables | Task.regardingobjectid |
| Connections | Ad-hoc, untyped link between any rows | Loose “related to” associations |
Cascade behavior (1:N relationship behavior)
- Parental — actions on the parent cascade to children (delete the parent, delete the children). Use sparingly; it is the strongest coupling.
- Referential — children survive; the lookup is simply cleared or blocked. *Referential, Restrict Delete* blocks deleting a parent that still has children.
- Configurable cascade — set each action independently: *Assign*, *Share/Unshare*, *Reparent*, *Delete*, and *Merge* can each be Cascade All / Active / User-owned / None.
Parental cascades multiply work
A parental relationship re-shares, re-assigns, and deletes down the whole tree in one transaction. On high-volume tables that can turn a single assign into thousands of row updates — prefer *Referential* unless the child genuinely cannot exist without its parent.
Web API basics
The Web API is an OData v4 service at https://<org>.crm.dynamics.com/api/data/v9.2/. Everything is JSON over HTTPS with an OAuth 2.0 bearer token (Microsoft Entra ID). You address a table by its entity set name — usually the logical name pluralized (account → accounts, contoso_widget → contoso_widgets).
| Method | Operation | Example path |
|---|---|---|
| GET | Retrieve a row / collection | /accounts(<id>) · /accounts?$top=10 |
| POST | Create a row | /accounts |
| PATCH | Update — or upsert by id/alternate key | /accounts(<id>) |
| DELETE | Delete a row | /accounts(<id>) |
| POST | Call an unbound/bound action | /WinOpportunity · /accounts(<id>)/Microsoft.Dynamics.CRM.Merge |
| GET | Call a function | /WhoAmI() |
| POST | Batch / changeset | /$batch |
| Header | Value / purpose | When |
|---|---|---|
| Authorization | Bearer <token> | Always (Entra OAuth) |
| OData-MaxVersion | 4.0 | Always |
| OData-Version | 4.0 | Always |
| Accept | application/json | Always |
| Content-Type | application/json | POST / PATCH |
| Prefer: return=representation | Echo the created/updated row back | POST / PATCH when you need the result |
| Prefer: odata.include-annotations="*" | Return formatted values + lookup names | GET when you need labels |
| Prefer: odata.maxpagesize=N | Page size for large GETs | Paged reads |
| If-Match: <etag> / "*" | Optimistic concurrency / update-only | PATCH / DELETE |
| If-None-Match: "*" | Create-only (block update on upsert) | PATCH upsert |
| MSCRMCallerID: <systemuserid> | Impersonate another user | Delegated writes |
POST [Organization URI]/api/data/v9.2/accounts
OData-MaxVersion: 4.0
OData-Version: 4.0
Content-Type: application/json
Prefer: return=representation
{
"name": "Contoso Ltd",
"creditlimit": 50000,
"primarycontactid@odata.bind": "/contacts(00000000-0000-0000-0000-000000000001)"
}PATCH [Organization URI]/api/data/v9.2/contoso_orders(contoso_ordernumber='SO-1042')
If-None-Match: "*"
Content-Type: application/json
{ "contoso_total": 1299.00, "contoso_status": 1 }Bind lookups, don’t set the GUID
Set a lookup by posting "<navprop>@odata.bind": "/<entityset>(<id>)", not by writing the raw _x_value column. To clear a lookup, DELETE the single-valued navigation property: DELETE .../accounts(<id>)/primarycontactid/$ref.
Querying with OData
| Option | What it does | Example |
|---|---|---|
| $select | Return only these columns (always do this) | $select=name,revenue |
| $filter | Row criteria | $filter=revenue gt 100000 |
| $orderby | Sort | $orderby=revenue desc,name asc |
| $top | Take the first N rows | $top=10 |
| $count | Include a total count | $count=true |
| $expand | Pull related rows / lookups | $expand=primarycontactid($select=fullname) |
| $apply | Aggregate / group (analytics) | $apply=aggregate(revenue with sum as total) |
| Operator / function | Meaning | Example |
|---|---|---|
| eq / ne | Equal / not equal | statecode eq 0 |
| gt / ge / lt / le | Greater / less (or equal) | revenue ge 50000 |
| and / or / not | Boolean composition | revenue gt 1000 and statecode eq 0 |
| ( ) | Precedence grouping | (a eq 1 or a eq 2) and b eq 3 |
| contains() | Substring match | contains(name,'Contoso') |
| startswith() / endswith() | Prefix / suffix match | startswith(name,'A') |
| null | Is / is not set | primarycontactid eq null |
| Function | What it matches | Example |
|---|---|---|
| LastXDays / OlderThanXDays | Relative date windows | LastXDays(PropertyName='createdon',PropertyValue=7) |
| ThisMonth / Today / ThisFiscalYear | Calendar windows | Today(PropertyName='createdon') |
| EqualUserId / EqualUserTeams | Owned by me / my teams | EqualUserId(PropertyName='ownerid') |
| Above / Under / AboveOrEqual | Hierarchy traversal | Under(PropertyName='accountid',PropertyValue='<id>') |
| In / NotIn | Value set membership | In(PropertyName='statuscode',PropertyValues=['1','2']) |
| ContainValues | Multi-select choice contains | ContainValues(PropertyName='contoso_tags',PropertyValues=['1']) |
GET [Organization URI]/api/data/v9.2/accounts?
$select=name,revenue,statuscode&
$filter=revenue gt 100000 and statecode eq 0&
$orderby=revenue desc&
$expand=primarycontactid($select=fullname,emailaddress1)&
$top=25
Prefer: odata.include-annotations="OData.Community.Display.V1.FormattedValue"Page with the server’s nextLink
Set Prefer: odata.maxpagesize=N and read up to N rows; if more exist, the response carries an @odata.nextLink URL — follow it for the next page rather than building your own $skip. Use $count=true for a total, and read choice/lookup labels from the ...@OData.Community.Display.V1.FormattedValue annotations.
FetchXML
FetchXML is Dataverse’s XML query language. It backs saved views and the SDK’s RetrieveMultiple, and it does a few things OData does more awkwardly — aggregates/grouping, deep link-entity joins, and distinct. Many makers author it in the FetchXML Builder (XrmToolBox) and paste the result.
<fetch top="50">
<entity name="account">
<attribute name="name" />
<attribute name="revenue" />
<filter type="and">
<condition attribute="statecode" operator="eq" value="0" />
<condition attribute="revenue" operator="gt" value="100000" />
</filter>
<link-entity name="contact" from="contactid" to="primarycontactid" alias="pc">
<attribute name="fullname" />
</link-entity>
<order attribute="revenue" descending="true" />
</entity>
</fetch>| Operator | Matches | Notes |
|---|---|---|
| eq / ne | Equal / not equal | value attribute holds the literal |
| like / not-like | SQL wildcard (%) | value="%Contoso%" |
| in / not-in | Value set | Nested <value> elements |
| null / not-null | Is / is not set | No value needed |
| on / on-or-after / on-or-before | Date comparisons | For date columns |
| last-x-days / next-x-days | Relative date window | value = number of days |
| eq-userid / ne-userid | Current user | No value — uses caller |
| under / eq-or-under / above | Hierarchy traversal | Hierarchical relationships |
<fetch aggregate="true">
<entity name="opportunity">
<attribute name="estimatedvalue" alias="total" aggregate="sum" />
<attribute name="opportunityid" alias="cnt" aggregate="count" />
<attribute name="ownerid" alias="owner" groupby="true" />
<filter>
<condition attribute="statecode" operator="eq" value="0" />
</filter>
</entity>
</fetch>FetchXML ⇄ OData, at a glance
<entity name> → the entity set · <attribute> → $select · <filter>/<condition> → $filter · <link-entity> → $expand (or a nested filter) · <order> → $orderby. Reach for FetchXML when you need aggregate/groupby or distinct; reach for OData for everyday CRUD and when you want a plain REST URL. You can also run FetchXML *through* the Web API via ?fetchXml=<url-encoded>.
Plug-ins & the SDK
A plug-in is a sandboxed .NET class implementing `IPlugin` that the platform runs when a message (Create, Update, Delete, a custom API…) fires against a table. You register it on a stage of the event pipeline with the Plugin Registration Tool or pac plugin. Inside, you reach the platform through the IServiceProvider.
| Stage | Value | In transaction? | Use it for |
|---|---|---|---|
| PreValidation | 10 | No (before tx, before security) | Cancel early; cross-row validation |
| PreOperation | 20 | Yes | Change values on the inbound row |
| MainOperation | 30 | Yes | Platform only (and custom APIs) |
| PostOperation | 40 | Yes | React after the write; async work |
| Member | What it gives you | Notes |
|---|---|---|
| InputParameters["Target"] | The inbound Entity (or EntityReference) | Entity on Create/Update; ref on Delete |
| PreEntityImages | Snapshot before the write | Register the image to use it |
| PostEntityImages | Snapshot after the write | PostOperation only |
| OutputParameters["id"] | New row id on Create | PostOperation |
| MessageName / PrimaryEntityName | Which message / table fired | Branch on these |
| Depth | Re-entrancy counter | Guard against infinite loops |
| UserId / InitiatingUserId | Effective vs calling user | Use for the service context |
| IOrganizationServiceFactory | Creates IOrganizationService | Pass UserId or null (SYSTEM) |
| ITracingService | Trace() for the plug-in trace log | Your only real debugger in prod |
public class AccountPreOperation : IPlugin
{
public void Execute(IServiceProvider serviceProvider)
{
var context = (IPluginExecutionContext)serviceProvider
.GetService(typeof(IPluginExecutionContext));
var tracing = (ITracingService)serviceProvider
.GetService(typeof(ITracingService));
var factory = (IOrganizationServiceFactory)serviceProvider
.GetService(typeof(IOrganizationServiceFactory));
var service = factory.CreateOrganizationService(context.UserId);
// Guard against re-entrancy (our own Update re-triggering us).
if (context.Depth > 1) return;
if (context.InputParameters.TryGetValue("Target", out var t) && t is Entity account)
{
tracing.Trace("Validating account " + account.Id);
var name = account.GetAttributeValue<string>("name");
if (string.IsNullOrWhiteSpace(name))
throw new InvalidPluginExecutionException("Account name is required.");
// PreOperation: set values on the inbound row — no extra Update needed.
account["contoso_normalizedname"] = name.Trim().ToLowerInvariant();
}
}
}- Sync vs async: synchronous steps run inline (10/20/40) and block the caller; asynchronous steps run after commit via the system-job queue and may only register on PostOperation (40).
- Cancel an operation by throwing
InvalidPluginExecutionException— ideally in PreValidation, before the transaction and security checks. - Images beat re-reading: register a Pre/Post image to get old/new values instead of calling
Retrieveinside the plug-in. - Sandbox isolation: no file system or registry, outbound HTTP/HTTPS only, and a ~2-minute execution limit — keep plug-ins fast and side-effect-light.
Always check Depth
A plug-in that updates its own table on Update will re-trigger itself. Guard with if (context.Depth > 1) return; (or scope your step’s filtering attributes) so a routine save does not recurse into a loop and trip the depth limit.
Security roles & access
Dataverse uses role-based access control. A security role grants a privilege (an action) at an access level (how far it reaches) per table — together those are the user’s *access rights*. A user can hold several roles, and access is the least-restrictive union of them all — you cannot subtract access with another role.
| Privilege | Allows | Notes |
|---|---|---|
| Create | Make a new row | |
| Read | Open and view a row | The gate for everything else |
| Write | Change a row | |
| Delete | Permanently remove a row | |
| Append | Attach this row to another | e.g. add a note to a case |
| AppendTo | Let others attach to this row | N:N needs Append on both tables |
| Assign | Give ownership to another user | |
| Share | Grant access while keeping yours | Ad-hoc, per-row grant |
| Access level | App label | Reach |
|---|---|---|
| None | None | No access |
| Basic | User | Rows you own, are shared, or your team owns |
| Local | Business Unit | All rows in your business unit |
| Deep | Parent: Child Business Units | Your BU and every BU beneath it |
| Global | Organization | Every row in the environment |
- Business units form the hierarchy that Local/Deep/Global are measured against; every user and team belongs to exactly one.
- Teams carry roles too: *Owner* teams can own rows; *Access* teams grant per-row access at scale; Microsoft Entra group teams sync membership from a security group — the cleanest way to manage access.
- Field-level security secures individual columns (e.g. salary, national ID) via Field Security Profiles that grant Read / Update / Create on the secured column — independent of table privileges.
- Hierarchy security layers Manager or Position hierarchies on top of roles so managers see their reports’ rows without a custom role.
Grant up, never patch down
Because roles are purely additive, the fix for “this user sees too much” is never to add a restrictive role — it is to lower the access level on the role that over-granted. Model from least privilege up. A handy check: WhoAmI plus the user’s roles tells you the effective business unit the access levels resolve against.
Web API patterns
Recipes that combine the pieces above — each is a copy-and-adapt request for real integration work.
Atomic multi-write with a $batch changeset
POST [Organization URI]/api/data/v9.2/$batch
Content-Type: multipart/mixed; boundary=batch_AAA
--batch_AAA
Content-Type: multipart/mixed; boundary=changeset_BBB
--changeset_BBB
Content-Type: application/http
Content-ID: 1
POST /api/data/v9.2/accounts HTTP/1.1
Content-Type: application/json
{ "name": "Contoso Ltd" }
--changeset_BBB
Content-Type: application/http
Content-ID: 2
POST /api/data/v9.2/contacts HTTP/1.1
Content-Type: application/json
{ "lastname": "Reed", "parentcustomerid_account@odata.bind": "$1" }
--changeset_BBB--
--batch_AAA--Optimistic concurrency (don’t clobber a newer write)
PATCH [Organization URI]/api/data/v9.2/accounts(<id>)
If-Match: W/"1234567"
Content-Type: application/json
{ "creditlimit": 75000 }Associate / disassociate an N:N
POST [Organization URI]/api/data/v9.2/accounts(<id>)/contoso_account_contact_set/$ref
Content-Type: application/json
{ "@odata.id": "[Organization URI]/api/data/v9.2/contacts(<contactid>)" }Impersonate another user for a delegated write
POST [Organization URI]/api/data/v9.2/tasks
MSCRMCallerID: 7b1f8a90-0000-0000-0000-000000000abc
Content-Type: application/json
{ "subject": "Follow up", "regardingobjectid_account@odata.bind": "/accounts(<id>)" }Choosing an access path
| Path | Best for | Notes |
|---|---|---|
| Web API (REST/OData) | Cloud flows, JS, any language, integration | No SDK needed; OAuth bearer token |
| SDK for .NET (IOrganizationService) | Plug-ins, Azure Functions, console apps | Strongly typed; Create/Update/Retrieve(Multiple) |
| FetchXML | Aggregates, group-by, distinct, saved views | Run via SDK or ?fetchXml= on the Web API |
| Dataverse connector | Power Automate / Power Apps, no code | List rows, filter rows (OData), bound actions |
Same model, many doors
Logical names, choice values, relationships, and the security model are identical across all four paths — learn the metadata once and it carries from a canvas app filter to a plug-in to a $batch integration.
Tip
Every cheat table above is searchable
Use the filter box on each table to jump to a column type, operator, privilege, or pipeline stage fast. This whole guide is generated from one structured dataset, so it stays in sync with the MCP reference.