Development 29 September 2026 7 min read

Reading N:M relationships in Power Pages: Web API vs. Liquid FetchXML

The portal Web API stumbles on reading N:M relationships. Why it happens, what Microsoft documents, and how Liquid FetchXML solves it server-side.

The use case is mundane: show the products linked to an installation through a many-to-many relationship. You reach for the portal Web API, call the navigation path, and get an error instead of data.

Then the usual hunt in the wrong places begins: site settings, permissions, the spelling of the navigation property. The truth is more nuanced than "the Web API can't do N:M". This post shows two ways: what the portal Web API really can do (and where it stumbles) and how Liquid FetchXML solves N:M reads server-side. I reproduced both in the demo portal (enhanced data model, two custom tables Installation and Product with an N:M relationship). A third way – Server Logic (on-demand, server-side JavaScript) – gets its own follow-up post.

Way 1: Portal Web API – what it does and where it stumbles

The portal Web API (/_api/...) offers, per Microsoft, a subset of Dataverse operations: read, create, update/delete, and associate/disassociate. $expand is documented for single-valued (N:1) and collection-valued (1:N) navigation properties, the latter only one level deep. An N:M $expand example is missing from the portal docs entirely.

The solid, documented N:M catch is a Known Issue. Microsoft writes, verbatim:

"Users get a CDS error if they invoke a GET Web API request for tables that have multiple levels of one-to-many or many-to-many table permissions when Parental, Contact, or Account scopes add more conditions to the query. To resolve this issue, use FetchXML in the OData query."

That "use FetchXML" is the official way out – more on it shortly. What does work cleanly through the portal Web API is associating and disassociating N:M relationships via $ref (confirmed in the demo):

webapi.safeAjax({
  type: "POST",
  url: "/_api/pwrprtl_installations(" + installationId + ")/pwrprtl_installation_product/$ref",
  contentType: "application/json",
  data: JSON.stringify({
    "@odata.id": baseUrl + "/_api/pwrprtl_products(" + productId + ")"
  })
});

Takeaway: writing N:M (associate) via the Web API is unproblematic. Reading N:M conveniently via $expand is not – that's what the FetchXML path is for.

Way 2: Liquid FetchXML – Microsoft's own workaround

FetchXML resolves N:M server-side through nested link-entity elements, with the automatic intersect table serving as the join via intersect="true". In a web template:

{% fetchxml products %}
<fetch>
  <entity name="pwrprtl_product">
    <attribute name="pwrprtl_product_name" />
    <link-entity name="pwrprtl_installation_product" from="pwrprtl_productid" to="pwrprtl_productid" intersect="true">
      <link-entity name="pwrprtl_installation" from="pwrprtl_installationid" to="pwrprtl_installationid">
        <filter>
          <condition attribute="pwrprtl_installationid" operator="eq" value="{{ installationId }}" />
        </filter>
      </link-entity>
    </link-entity>
  </entity>
</fetch>
{% endfetchxml %}

<ul>
{% for p in products.results.entities %}
  <li>{{ p.pwrprtl_product_name }}</li>
{% endfor %}
</ul>

In the demo portal this returns exactly the products linked to an installation:

The Liquid FetchXML page in the portal lists the products linked to an installation through the N:M relationship

The trap that cost me time while reproducing this (and that will bite you the same way): Liquid FetchXML runs in the context of the signed-in user and respects their web role and table permissions. When I opened the page anonymously, it returned an empty list – no error, nothing. Only once signed in as a portal contact (with a web role that has read table permissions on the involved tables) did the data appear. So set read table permissions on the involved tables for the relevant web role; I also set a permission on the intersect table and didn't isolate whether it's strictly required – grant it to be safe.

Characteristic: the data is ready at page render (preloaded). Ideal when the list is visible right away anyway.

Decision guide

Task Recommended way
Read N:M, list visible at page load Liquid FetchXML (link-entity/intersect)
Write N:M (associate/disassociate) Portal Web API $ref (POST/DELETE)
Read 1:N / N:1 Portal Web API $expand (one level) or Liquid

The real lesson is less about "which way works" than about context: both ways respect the user's web role and table permissions. An empty result is therefore often not a bug in your code, but a missing permission or an unauthenticated user. Before you take the FetchXML apart, first check who the page is running as.

Coming up: there's a third way to fetch N:M data – Server Logic, server-side JavaScript the client calls on-demand (generally available since 2026). When it beats the preloaded FetchXML approach, and what to watch out for, is the subject of a separate follow-up post.

Related articles

Sources

Tino Rabe

Tino Rabe

Power Pages Spezialist · Former Microsoft MVP

Power Pages specialist, former Microsoft MVP. I help companies build secure customer portals: architecture workshop, weekly coaching, security audits.

When was your portal last independently reviewed?

Fixed-fee security audit, or just talk it through first.

Book a call