Kontakt os

PnP Core vs PnP.Framework: Why I Haven't Switched Yet

I’ve been building an internal shared library. The idea is boring and useful: collect the code I write over and over into one place, keep it as up to date as I can, and make it battle tested rather than “worked on my tenant last Tuesday”. Testing with Dev Proxy instead of hope - I’ve simulated throttling with it enough now to trust what it tells me.

That kind of library is exactly the moment to pick your foundation on purpose. So I sat down and looked hard at PnP Core.

And then I stayed on PnP.Framework.

What Makes PnP Core Tempting

The thing I actually want from PnP Core isn’t a feature. It’s that somebody already made a decision I keep having to make myself.

With CSOM and raw calls, every time I read something out of SharePoint I have to think about how: SharePoint REST, or Microsoft Graph? Which one has this property? Which one is faster here? PnP Core removes that question. Per the docs, the SDK “by default is configured to favor the Microsoft Graph API when you’re reading SharePoint data assuming the requested properties are available via Graph”, and it falls back to SharePoint REST when they aren’t. If you disagree, you flip GraphFirst to false on the PnPContext and it prefers REST instead.

Someone with more context than me picked the better call per operation, and I get to write the intent instead of the plumbing. Add async-first, a real domain model, and modern .NET hosting, and it’s not a close comparison on paper.

The problem is that my code doesn’t run on paper.

Blocker 1: The Provisioning Engine Isn’t There

Almost everything I build for SharePoint eventually provisions something. Lists, content types, pages, navigation. That’s ApplyProvisioningTemplate, and it lives in PnP.Framework. PnP Core has no provisioning engine.

So a migration wasn’t “rewrite my queries”. It was “rewrite my queries and keep PnP.Framework around anyway for the part that does the heavy lifting”. Two SDKs, two mental models, no win.

That’s the part I’d already made peace with, until pnpframework issue #1237 showed up in June: a roadmap for moving both the Provisioning Engine and the Modernization Engine into PnP Core as dedicated projects, with the PnP.Framework versions deprecated afterwards. Target is Q4 2026.

That single issue turns my biggest objection into a waiting game. I don’t need PnP Core to grow a provisioning engine as a favor to me. It’s on the roadmap, in the open, with a phase 2 already referenced for the wider PnP.Framework deprecation. Everything I’ve written about splitting templates into modular files for retry resilience survives that move, because it’s a property of how you structure templates, not of which SDK applies them.

Blocker 2: CAML Joins Come Back Empty

The second one I couldn’t wait out, because it’s the query pattern I lean on hardest.

SharePoint’s CAML supports joining lists and pulling fields across the join with <Joins> and <ProjectedFields>. It’s the difference between one query and four, and I’ve written a whole post on doing it with CAMLEX in CSOM because it saves resource units and spares you from merging rows in C#.

Run that same view XML through PnP Core’s LoadItemsByCamlQueryAsync and nothing breaks. No exception, no error, no warning. You get items back, the join is honored for filtering, and the projected fields simply aren’t in the result. The REST GetItems endpoint doesn’t serialize them, so they never make it into the response for the SDK to map.

A silent hole in the data is worse than a thrown exception, and it’s a hole in the one query shape I most wanted to keep.

So I filed issue #1799 and then PR #1802 with an implementation: two new methods on IList that run the query through CSOM’s List.GetItems(CamlQuery) instead of REST, because CSOM does return projected fields.

/// <summary>
/// Loads list items based up on a CAML query executed via CSOM, which also returns
/// fields projected from a joined list (CAML Joins/ProjectedFields)
/// </summary>
public Task<ICamlQueryCsomResult> LoadItemsByCamlQueryViaCsomAsync(CamlQueryOptions queryOptions);

public ICamlQueryCsomResult LoadItemsByCamlQueryViaCsom(CamlQueryOptions queryOptions);

Using it looks like any other PnP Core call. This is the shape from the integration test in the PR:

var page1 = await list.LoadItemsByCamlQueryViaCsomAsync(new CamlQueryOptions()
{
    ViewXml = viewXml,
    DatesInUtc = true
});

var firstItem = page1.Items[0];
var projected = firstItem["ProjectedText"] as IFieldLookupValue;
Console.WriteLine(projected.LookupValue);

// Next page, without hand-building a paging string
var page2 = await list.LoadItemsByCamlQueryViaCsomAsync(new CamlQueryOptions()
{
    ViewXml = viewXml,
    DatesInUtc = true,
    PagingInfo = page1.PagingInfo
});

What’s happening here?

  1. LoadItemsByCamlQueryViaCsomAsync takes the exact same CamlQueryOptions as the REST-based method, so the view XML with <Joins> and <ProjectedFields> is unchanged. Only the transport differs.
  2. The projected field comes back typed as an IFieldLookupValue, not as a raw string. That’s how CSOM represents it and it’s what you’d expect from a lookup coming across a join, so LookupId and LookupValue are both there.
  3. The returned ICamlQueryCsomResult carries Items plus PagingInfo, taken from CSOM’s ListItemCollectionPosition. Feed PagingInfo back into the next call and you get the next page. It’s null when there are no more pages.
  4. The loaded items are also merged into the list’s Items collection, the same as the existing CAML methods, so nothing about the surrounding model changes.

Gotchas

  • The failure mode is silence, not an error. No exception on a join query with projected fields means your code happily maps a null and moves on. If you’re evaluating PnP Core for join-heavy work, assert on the projected value in a test, don’t eyeball the item count.
  • A roadmap is a plan, not a shipped release. Issue #1237 has a Q4 2026 target and it’s still open. Same for my PR: open, no reviewer assigned yet. I’m building on the current state of both SDKs, not on the version I hope exists in six months.
  • No batch variant for the CSOM path, on purpose. The items are materialized immediately, so it doesn’t fit PnP Core’s batching model. If you’re used to queueing everything into a batch, this one call stands apart.
  • Deprecation warnings are part of the plan. Once the engines land in PnP Core, the PnP.Framework equivalents get marked deprecated and stop taking new features. Staying put is a decision with an expiry date, which is exactly why I’d rather have the blockers resolved than keep postponing.

Wrapping Up

I’m not avoiding PnP Core because I prefer PnP.Framework. I’m on PnP.Framework because two specific things weren’t there: the provisioning engine, and CAML joins that actually return the joined data. Both now have an issue number attached, and one of them has my name on the pull request.

Rule of thumb: pick the SDK by what your workload actually needs today, then go make the gap smaller instead of waiting for someone else to. Filing #1799 took an evening. It moved my migration date more than another month of reading release notes would have.

When the provisioning engine lands in PnP Core and projected fields come back from a join, I’m switching and I’m not looking back.

Pinksky bruger cookies for at forbedre funktionaliteten på denne hjemmeside. Du bestemmer selv hvad vi må bruge. Læs mere i vores cookiepolitik.