Virtual tables let you surface live data from SQL Server, REST APIs, and OData services directly inside your model-driven app — no ETL, no data copy, no sync. This lesson teaches you to configure OData data sources, map columns, build cross-system relationships, and handle the security and performance challenges that come with production virtual table deployments.

Picture this: your organization runs a model-driven app for customer relationship management in Dataverse, but the product catalog, pricing, and inventory data all live in a SQL Server database managed by a separate IT team. The data is current, well-maintained, and definitely not moving anywhere. Your app users need to see product details alongside Dataverse records, but nobody wants to build a nightly ETL job, manage sync conflicts, or own a stale copy of data that changes hourly.
This is exactly the problem Dataverse virtual tables were designed to solve. A virtual table looks and behaves like a native Dataverse table — it appears in your model-driven app's site map, supports views, forms, lookups, and subgrids — but its data lives entirely in an external system. When a user opens a record, Dataverse fetches it live from the source. No migration. No copy. No drift.
By the end of this lesson, you'll be able to design and configure virtual tables using both the OData connector (the simplest built-in approach) and the Virtual Table Provider interface, wire them into a model-driven app with proper relationships and security, and diagnose the performance and permission problems that trip up practitioners in production.
What you'll learn:
You should be comfortable working inside Dataverse and the Power Platform admin center. Familiarity with Dataverse tables, columns, and rows is assumed, as is a working knowledge of model-driven app construction. If you've never configured a Dataverse data model with relationships, read through Designing a Dataverse Data Model: Relationships, Lookups, and Choice Columns first — virtual table relationships follow the same patterns.
Before you touch any configuration, you need a mental model of the moving parts. Understanding the architecture saves you from a lot of frustrated head-scratching later.
A virtual table in Dataverse is a table definition — schema only — that has no storage rows in the Dataverse database. Instead, it's linked to a Data Provider (also called a Virtual Table Provider). When the Dataverse platform receives a query against a virtual table (from a model-driven app view, a Power Automate flow, an API call, or anything else), it routes that query to the registered provider, which translates it into a call against the external system, fetches the results, and returns them in a format Dataverse can render.
The provider handles four operations:
| Operation | What Triggers It |
|---|---|
| Retrieve | Opening a single record form |
| RetrieveMultiple | Loading a view, subgrid, or lookup |
| Create | Saving a new record (if writeback is configured) |
| Update / Delete | Editing or removing an existing record (if configured) |
Microsoft ships two built-in providers:
For everything else — legacy REST APIs, SOAP services, file-based sources, streaming platforms — you build a custom provider using a Dataverse plugin that implements the IPlugin interface against retrieve and retrieveMultiple messages on your virtual table. That's an advanced topic we'll touch on conceptually, but our hands-on work uses OData v4, which covers the majority of real-world cases.
Key insight
Virtual tables delegate query filtering to the provider. For the OData provider, filter conditions are translated into $filter parameters on the OData request. If your endpoint doesn't support OData query options (like $filter, $orderby, $top), the provider will fetch all records and let Dataverse filter in memory — which is slow and will fail dramatically at scale. Always verify your endpoint's OData capability before committing to a virtual table approach.
Virtual tables are not always the right answer. Before configuring one, validate that you're solving the right problem.
Choose virtual tables when:
Choose data import or dataflows when:
Warning
Several high-value Dataverse features simply don't work on virtual tables. Business rules have limited support. Formula columns and rollup columns cannot be created on virtual tables. Auditing and change tracking are not available. If any of these are requirements, reconsider whether importing data and accepting a copy is actually the better trade-off.
For this lesson, we'll use a realistic scenario: a manufacturing company runs an inventory management system in Azure SQL, exposed via an ASP.NET Core Web API with OData v4 support. You, as the Power Platform practitioner, need to surface product and inventory data in the CRM model-driven app without copying it.
Your external endpoint looks like this:
Base URL: https://inventory-api.contoso.com/odata
Entity: Products
A sample OData response for a product might look like:
{
"@odata.context": "https://inventory-api.contoso.com/odata/$metadata",
"value": [
{
"ProductId": "PROD-1042",
"ProductName": "Hydraulic Coupling - 2in",
"UnitPrice": 48.75,
"StockQuantity": 240,
"WarehouseCode": "WH-EAST",
"LastUpdated": "2024-11-15T09:22:00Z",
"IsActive": true
}
]
}
Before touching Power Platform, verify three things about your endpoint:
1. Metadata document is available. Navigate to /odata/$metadata in a browser. You should see an XML EDMX document describing entity types, keys, and properties. If this URL 404s, the OData provider cannot auto-discover your schema.
2. Authentication method is compatible. The OData v4 Data Provider supports:
3. The entity has a string key. Dataverse virtual tables require the external entity's primary key to map to a Dataverse primary key column, which must be a string. If your system uses integer IDs (like INT ProductId), you have two options: cast it to string in your API response, or use an OData computed key. Don't skip this — it's the most common setup failure.
With a working, authenticated endpoint in hand, you register it in Dataverse as a Virtual Table Data Source.
Navigate to Power Apps (make.powerapps.com) → Your Environment → Dataverse → Virtual Table Data Sources. If you don't see this in the left nav, go to Tables, then look in the top menu for More → Virtual Table Data Sources.
Click New, and select OData v4 Data Provider from the provider list.
Fill in the form:
https://inventory-api.contoso.com/odata120 for development; tune down once you know your endpoint's response timeYes if your endpoint supports $count in OData queries (enables accurate pagination in views)For authentication, if your API uses OAuth 2.0:
https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/tokenSave the data source. Dataverse will attempt to fetch the metadata document. If you see an error here, the URI is wrong or the service isn't reachable from Power Platform's outbound IP ranges — check your firewall rules.
Tip
If your external system is on-premises rather than cloud-hosted, you'll need to route the OData connection through the on-premises data gateway. The OData v4 Data Provider supports gateway connections — set this up in Power Platform admin center under Data → Gateways before registering the data source. The configuration mirrors what you'd do when connecting Canvas Apps to on-premises systems via gateway.
Now create the actual virtual table. Go to Power Apps → Tables → New table → Create a virtual table.
The wizard walks you through four phases.
Select the data source you just registered: Contoso Inventory OData. Dataverse fetches the metadata document and presents a dropdown of available entity types. Select Products.
This is where you define the Dataverse identity of the virtual table:
contoso_inventoryproduct — this follows your solution publisher prefixNote
Always create virtual tables inside a solution with a proper publisher prefix. Tables created outside a solution land in the Default solution and become difficult to manage across environments. If you're unfamiliar with solution structure, Solutions for Model-Driven Apps covers this in detail.
ProductName from your OData entity.Map your external entity's primary key to the virtual table's primary key. In our case:
ProductIdIf ProductId in your source is an integer, the OData provider offers a coercion option — enable Convert primary key to string. This casts the integer to a string during retrieval.
This is the most detail-intensive step. For each external property you want accessible in Dataverse, you create a mapped column. The wizard shows a list of properties from the OData metadata; you select each one and configure the Dataverse column details.
| OData Property | Dataverse Column Name | Dataverse Type | Notes |
|---|---|---|---|
ProductName |
contoso_productname |
Single Line of Text | Primary field |
UnitPrice |
contoso_unitprice |
Currency | OData Decimal maps to Currency |
StockQuantity |
contoso_stockquantity |
Whole Number | |
WarehouseCode |
contoso_warehousecode |
Single Line of Text | |
LastUpdated |
contoso_lastupdated |
Date and Time | |
IsActive |
contoso_isactive |
Two Options (Yes/No) |
After saving, Dataverse creates the virtual table with all mapped columns. Go to Tables and confirm contoso_inventoryproduct appears with a small virtual table icon (it looks slightly different from native tables in the list).
Before building any app UI, verify the table retrieves data correctly. The fastest way is to open the table in Power Apps and use the Data view — the tab that shows actual rows in the table.
Click into your contoso_inventoryproduct table and choose the Data tab. If the connection is working, you'll see a paginated grid of products from your external system. If you see an error:
<Key> element on the entity typeYou can also test retrieval via the Dataverse Web API directly. In a browser (while logged into the environment), navigate to:
https://yourorg.crm.dynamics.com/api/data/v9.2/contoso_inventoryproducts?$top=5
A valid JSON response confirms end-to-end connectivity.
Here's where virtual tables become genuinely powerful. Your CRM app has a native Account table (Dataverse's built-in customer table). You want to associate inventory products with accounts — perhaps to track which products a given customer can order.
You can create a lookup relationship from a native table to a virtual table. However, there are constraints:
For our scenario, we'll create a junction — a native CustomerProductCatalog table that has lookups to both Account and contoso_inventoryproduct.
Go to Tables → New table and create:
contoso_customerproductcatalogAdd two lookup columns:
Accountcontoso_inventoryproduct (your virtual table)When you configure the lookup to the virtual table, the relationship type is still one-to-many. The contoso_inventoryproduct table is the "one" side, contoso_customerproductcatalog is the "many" side. This works because Dataverse stores the external key value (the string product ID) in the lookup column on the native table — it's just a stored ID, not a foreign key constraint in a relational database sense.
Warning
Cascade delete behaviors don't work on relationships involving virtual tables. If a product is deleted in the external system and you have CustomerProductCatalog records pointing to its ID, those lookup columns will show an error or blank value. Build a process to handle orphaned references — this is a real operational concern.
For detailed guidance on how relationship types work and what cascade options are available on native tables, see Configuring Dataverse Table Relationships in Model-Driven Apps.
Adding a virtual table to a model-driven app follows the same process as any native table. Open your model-driven app in the App Designer, and in the site map, add a new subarea pointing to the contoso_inventoryproduct virtual table.
Virtual tables support views. Go to Tables → contoso_inventoryproduct → Views and create a view:
ProductName, UnitPrice, StockQuantity, WarehouseCode)$filter parameters$orderby in the OData requestKey insight
Filters in virtual table views only work if your OData endpoint supports the corresponding OData query options. A filter on IsActive eq true becomes ?$filter=IsActive eq true in the OData request. If the API ignores $filter and returns all records regardless, your view filter will appear to work (Dataverse post-filters in memory) but performance will be terrible. Test by monitoring network traffic or API logs while loading the view.
For advanced view configuration techniques, including how to set views as default, lookup, or quick-find views, see Configuring Model-Driven App Views as Default Views, Quick Find Views, and Lookup Views.
Add a main form to the virtual table in Tables → contoso_inventoryproduct → Forms. The form designer works identically to a native table form — drag and drop columns onto sections and tabs.
One difference: you cannot add subgrid controls that display native Dataverse relationships from the virtual table side. You can, however, add a subgrid on a native table's form that shows related virtual table records through the contoso_customerproductcatalog junction.
For form design patterns — how to structure sections, tabs, and subgrids effectively — the guidance in Designing Model-Driven Forms: Sections, Tabs, Subgrids, and Quick View Forms applies directly to virtual table forms.
Security on virtual tables has two layers that you need to think about independently.
Just like native tables, virtual tables are governed by Dataverse security roles. A user must have at least Read privilege on the virtual table's entity to see records in views or open forms.
Go to Security Roles → [Your Role] → Custom Entities and find contoso_inventoryproduct. Grant Read at the organization level (since virtual tables don't have per-record ownership — they're owned externally).
Warning
Virtual tables do not support user or business unit level security scoping the way native tables do. Every user who has Read access to the virtual table can potentially see every record in the external system that the registered service principal can access. If you need row-level security on external data, it must be enforced in the external system — either by parameterizing the OData query based on the authenticated user, or by building a custom provider that applies filtering based on Dataverse user context.
For a complete picture of how Dataverse security roles work and how to design them for real applications, see Dataverse Security: Business Units, Security Roles, and Teams.
The virtual table provider authenticates to the external system using the credentials stored in the data source configuration — typically a service principal or API key. This means all Dataverse users share a single set of credentials when hitting the external API. The external system sees one authenticated caller (your service principal), not individual Dataverse users.
This has implications:
By default, virtual tables are read-only in the sense that Dataverse won't attempt Create, Update, or Delete operations unless the provider and the virtual table are configured to support them.
For the OData provider, write-back works if:
To allow editing, no special configuration flag is needed — simply ensuring the endpoint supports the HTTP methods is sufficient. Users with Write privilege on the virtual table's security role will be able to edit records, and the OData provider will issue PATCH requests with the modified field values.
Tip
Be deliberate about which columns are editable. If StockQuantity should only be modified through the inventory management system's own processes (not through Dataverse), restrict edits at the external API layer — return the property as Computed or not-PATCHable in the OData metadata. Don't rely solely on Dataverse form configuration to prevent unwanted writes, because API clients bypassing the form could still issue update calls.
For data sources that don't speak OData v4 — a SOAP service, a legacy file-based system, a streaming database — you need to build a custom provider as a Dataverse plugin.
The architecture:
IPluginExecute, check context.MessageName to know which operation is being performedHttpClient, a vendor SDK, or whatever is appropriate)Entity (for Retrieve) or EntityCollection (for RetrieveMultiple)Here's a minimal skeleton for a RetrieveMultiple handler that calls a hypothetical REST API:
public class InventoryProductProvider : IPlugin
{
public void Execute(IServiceProvider serviceProvider)
{
var context = (IPluginExecutionContext)serviceProvider
.GetService(typeof(IPluginExecutionContext));
if (context.MessageName.Equals("RetrieveMultiple",
StringComparison.OrdinalIgnoreCase))
{
HandleRetrieveMultiple(context, serviceProvider);
}
}
private void HandleRetrieveMultiple(
IPluginExecutionContext context,
IServiceProvider serviceProvider)
{
// Get the query from the input parameters
var query = context.InputParameters["Query"] as QueryExpression;
// Call your external API
var httpClient = new HttpClient();
var response = httpClient.GetStringAsync(
"https://inventory-api.contoso.com/products").Result;
var products = JsonSerializer.Deserialize<List<ExternalProduct>>(response);
// Map to Dataverse EntityCollection
var collection = new EntityCollection();
collection.EntityName = "contoso_inventoryproduct";
foreach (var product in products)
{
var entity = new Entity("contoso_inventoryproduct");
entity.Id = Guid.Parse(HashToGuid(product.ProductId)); // stable GUID
entity["contoso_productname"] = product.ProductName;
entity["contoso_unitprice"] = new Money(product.UnitPrice);
entity["contoso_stockquantity"] = product.StockQuantity;
collection.Entities.Add(entity);
}
context.OutputParameters["BusinessEntityCollection"] = collection;
}
private string HashToGuid(string externalId)
{
// Deterministically convert string ID to GUID
using var md5 = MD5.Create();
var hash = md5.ComputeHash(Encoding.UTF8.GetBytes(externalId));
return new Guid(hash).ToString();
}
}
The critical detail in the custom provider is GUID stability. Dataverse identifies virtual table records by GUID. If you generate a random GUID each time a product is retrieved, Dataverse can't correlate a record across a list view and a detail view. You must deterministically map your external ID to a stable GUID — using a hash, a lookup table, or encoding the string ID into a GUID format directly.
Key insight
The hash-to-GUID approach works but has a theoretical collision risk with MD5. For production, use a name-based UUID (RFC 4122 version 5, using SHA-1) or GuidV5 libraries available in .NET. The collision probability with SHA-1 over typical entity counts is astronomically low, but it's worth doing correctly from the start.
Let's put the full workflow together with a realistic, end-to-end exercise. We'll use a publicly testable OData endpoint so you can complete this without standing up your own API.
The TripPin OData service (https://services.odata.org/TripPinRESTierService) is a Microsoft-maintained OData v4 sample service. We'll treat People as our "external contacts" and surface them in a model-driven app.
TripPin Sample Servicehttps://services.odata.org/TripPinRESTierService120YesExternal Contact (TripPin)contoso_trippin_personUserName (this is the string key for TripPin People)FirstName → contoso_firstname (Text)LastName → contoso_lastname (Text)Emails → Skip (collection types require custom handling)Gender → contoso_gender (Text)FirstName + LastName as the primary name (or create a calculated label — you can concatenate in the form)Open the contoso_trippin_person table and click the Data tab. You should see people from the TripPin service rendered as rows with names, genders, and other mapped fields.
contoso_trippin_personCreate a native ExtContactLink table that links one of your existing native records (say, an Account) to an External Contact:
Account External Contact (contoso_accountexternalcontact)Account (to Account table)External Contact (to contoso_trippin_person)Account External Contact recordsAccount External Contact and select an External Contact from the TripPin virtual table via the lookupYou now have a working cross-system relationship — a native Dataverse Account linked to an external person record — with no data migration.
Cause: The external endpoint is slow, or the OData provider is fetching all records and filtering in memory because the endpoint doesn't support $filter.
Fix: Add $filter support to your API. As a stopgap, increase the timeout on the data source. For views with large datasets, apply aggressive view filters to reduce result set size. The OData provider appends $top by default; verify your API respects it.
Cause: The primary key stored in the lookup column (the external ID converted to a GUID) doesn't match the GUID Dataverse generates when retrieving that record — usually because the key mapping or hash function isn't consistent.
Fix: Verify the external key field and the hash/conversion logic are deterministic. Open the Dataverse Web API URL for a known record and confirm the GUID matches what's stored in the lookup column on the related native table.
Cause: Virtual tables don't support per-record ownership or business unit scoping natively. If a user has organization-level Read, they see everything the service principal can access.
Fix: Row-level filtering must happen in the external system. Pass user identity to the external API (requires a custom provider that can access context.InitiatingUserId and translate it to an external user/role) or restrict the service principal's data access scope to match what all Dataverse users should be able to see.
Cause: The Retrieve operation is failing silently, usually because the record's GUID doesn't translate back to a valid external ID for the single-record fetch.
Fix: Check the OData provider's behavior when fetching by key. The provider constructs a URL like /Products('PROD-1042') using the external key value. If the external key wasn't preserved correctly through the GUID conversion, the API returns 404 and the form shows blanks. Enable Dataverse diagnostics (Power Platform admin center → Environments → Diagnostics) to see the actual OData request being made.
Cause: The OData $filter syntax generated by Dataverse uses functions (like contains() for text search) that your endpoint doesn't implement.
Fix: Test each filter operator you use in views against your endpoint directly in a browser. Unsupported operators must be removed from views; if they're essential, the external API must implement them.
Cause: The virtual table is not in the solution associated with your app, or it was created outside any solution.
Fix: Add the virtual table to your solution explicitly: Solutions → [Your Solution] → Add existing → Table → contoso_inventoryproduct. Then re-open the app designer.
Virtual tables trade storage efficiency for query overhead. Every interaction with a virtual table is an outbound network call. In production, this means:
Latency adds up in subgrids. If a form has a subgrid showing related virtual table records, loading that form makes at minimum two API calls — one for the main record, one for the subgrid's RetrieveMultiple. If each call takes 300ms, the user perceives a 600ms lag minimum before the form is fully populated. Consider whether a read-only quick view form or a link out to the external system UI is a better UX than an inline subgrid.
Caching doesn't exist natively. Dataverse doesn't cache virtual table results. If 20 users open the same product list simultaneously, that's 20 OData requests to your external API. Build caching at the API layer (Redis, CDN, ASP.NET response cache) if your external system can't handle the concurrency.
Connection pool management matters. The OData data provider establishes connections on demand. For high-traffic apps, ensure the external API can handle the maximum expected concurrent connection count from Power Platform's outbound infrastructure.
Consider alternate keys for lookups. Dataverse alternate keys on native tables can help when linking native records to external IDs — they give you a clean lookup path without relying solely on GUIDs. If you store the raw external product ID (like PROD-1042) in an alternate key column on a native table, you can perform deterministic lookups without the GUID translation complexity.
Virtual tables are one of Dataverse's most powerful and underutilized features. When configured correctly, they allow model-driven apps to present a unified view of organizational data — Dataverse-native and external — without the operational overhead of ETL pipelines, sync processes, or stale data copies.
Here's what you've built competence in:
Where to go next:
If your virtual table scenario involves read-only display of external data within forms that also show Dataverse-native fields, explore Adding Custom Pages to Model-Driven Apps — custom pages give you canvas-level control over how external data is presented alongside native data, which can be more flexible than virtual table forms for complex display scenarios.
For scenarios where your virtual table needs richer UI behavior — inline editing grids, custom visualizations, or formatting that the standard model-driven form can't provide — Extending Model-Driven Apps with PCF Controls gives you the toolkit to build dataset components that can render virtual table data with full control.