You start a SharePoint feature the sensible way. Graph first, because it’s the modern API and because that’s what PnP Core does by default. You write the upload. You write the metadata. You write the listing code. It works.
Then you hit the one field, the one property, the one checkbox Graph doesn’t do. And you hit it on day three, with the feature already shaped around Graph.
Sometimes rewiring it is cheap. Sometimes it’s a rewrite. Either way you paid.
I’ve walked into that wall enough times now that I stopped calling it bad luck and started writing down which tool I reach for when. This post is that list.
The 200 That Meant Nothing
The worst one was metadata on uploaded documents. My add-in uploads a file with Graph, then applies the library’s columns to the resulting list item with a PATCH to /listItem/fields. Text columns, choice columns, dates, all good.
Then a managed metadata column.
200 OK. Empty field.
Not a 400. Not “column type not supported”. Graph accepted the payload, answered like everything was fine, and wrote nothing. The Learn page for updating a listItem shows you Color and Quantity and never mentions which column types don’t make it through.
A thrown error costs you an hour. A silent success costs you until someone notices the taxonomy column is blank on documents that were archived weeks ago, and then it costs you a data migration.
That’s the real argument for having a playbook. Not elegance. The cases where the wrong choice fails quietly.
Be careful about which columns you write off, though. I had multi-value lookups and person columns on my “Graph can’t” list for a long time, and both were wrong. They work fine, they just don’t take the shape you’d guess coming from CSOM, and that’s its own post. Managed metadata is the one that genuinely has no Graph write path.
Graph First Is a Default, Not a Plan
To be clear, “Graph first” is a good instinct. PnP Core builds it in: the SDK favours Graph when reading SharePoint data and falls back to SharePoint REST when the requested properties aren’t available there, and you can flip GraphFirst off if you disagree. I’ve written before about why I’m still on PnP.Framework, and that per-operation routing is the thing I most want from PnP Core.
Until I have it, I’m the router. So here’s how I route.
CSOM Owns List Items
Roughly 95% of my list item CRUD is CSOM, and it’s not nostalgia. Three capabilities keep it there.
Batching without a hard ceiling. CSOM queues operations until you call ExecuteQueryAsync(), and around 100 operations per batch is the reliable sweet spot. Graph’s $batch caps at 20 requests, and a failed batch needs picking apart before you retry it. When I’m updating 500 items, that difference is 5 round trips versus 25.
CAML joins. One query across lists connected by lookup columns, filtered on a field two lists away, merged server-side. There’s no Graph equivalent, and I’ve never found a way to fake it that didn’t end in merging rows in C#.
A way past the list view threshold. Query a big list and CSOM throws The attempted operation is prohibited because it exceeds the list view threshold. There’s a flag on CamlQuery that gets you through it, combined with paging and indexed columns. That one deserves its own post and it’s on my list.
The rest of my CSOM reasoning is in the CSOM performance playbook, and the join pattern has its own post.
Graph Owns Files
For files it flips completely. Uploading and downloading through Graph is fewer requests and noticeably faster in every project I’ve measured it in, and large files are the clearest case: createUploadSession gives you a resumable, chunked upload with a pre-authenticated URL, and the chunks don’t carry an Authorization header at all.
// Small files: straight PUT to the content endpoint
if (file.size <= SIMPLE_UPLOAD_LIMIT) {
return folder.concat(`:/${filename}:/content`).put(file);
}
// Large files: a session, then sequential chunks
const session = await folder.createUploadSession({ name: filename });
for (const chunk of chunks) {
await session.resumableUpload.upload(chunk.length, chunk, contentRange);
}
The rules that matter: chunk sizes must be a multiple of 320 KiB, they go up sequentially, and each PUT extends the session expiry. Get the multiple wrong and the upload fails at the last chunk, which is a fun way to spend an afternoon.
Downloads get the same treatment - zipping a whole folder out of SharePoint is a Graph job for me, not a CSOM one. Graph’s JSON batching maps cleanly onto “fetch these 20 files’ content”, though matching responses back to requests has its own quirks that I covered in Graph batching for file content.
SharePoint REST Is the Escape Hatch
I almost never choose SharePoint REST. I end up there when it’s the only thing that works, which turns out to be more often than the modern-API story suggests. From my current projects:
- Setting a navigation link to open in a new tab. It’s a checkbox in the UI, it’s not in the PnP provisioning schema, and it’s not on CSOM’s
NavigationNode. It’sMenuState/SaveMenuState. I have a whole post coming about that one. - Reading the sites a user follows:
/_api/social.following/my/followed(types=4). - Joining a hub site, activating site features, applying a site design, adding an available content type to a library. All
_apicalls in my provisioning engine. ValidateUpdateListItem, which is what rescues the metadata story from the top of this post.- And
/_api/web/ensureuser, because Graph has noEnsureUserand no/sites/{id}/usersendpoint at all.
The pattern is consistent: the older and more SharePoint-specific the concept, the more likely REST is the only place it lives.
ValidateUpdateListItem is worth knowing by name. It takes form values in SharePoint’s own wire format - taxonomy as Label|GUID, people as a JSON array of claim keys - and it’s the same payload the classic edit form posts, which is exactly why it accepts the column types Graph won’t touch. In a C# backend it’s a method on ListItem in the client library; in a browser add-in with no CSOM available, it’s the REST endpoint. Either way, read the response: rejected fields come back with HasException: true inside an otherwise successful call, so you can recreate the silent-200 problem from the other direction if you don’t look.
The EnsureUser gap is the more interesting one, because it’s what stops person columns from being a pure Graph story. The ids in ReviewersLookupId are site-collection user ids from the User Information List. Not Entra object ids, not Graph user ids. If a user has never been referenced on that site, they simply have no id, and Graph gives you no way to create one. So you POST /_api/web/ensureuser with a logonName first, then write the item with Graph. Or you stay in CSOM and let Web.EnsureUser() plus a FieldUserValue do both in one place, which is what I usually do in a backend.
One nice asymmetry on the taxonomy side: reading the term store works fine over Graph (/sites/{id}/termStore/sets/{id}, with TermStore.Read.All). It’s only writing a term onto a list item that Graph won’t do. So my term picker is Graph and my term save is not.
Gotchas
- A
200is not proof. This is the whole post in one bullet. Graph accepts unsupported column types and writes nothing. Assert on the value you wrote, at least once per column type, in a real library. Otherwise you find out at migration time. - Mixing APIs means mixing tokens. A Graph token does not work against
/_api. SharePoint REST wants an audience ofhttps://<tenant>.sharepoint.com/.default, so a flow that uses both acquires two tokens and your app registration needs both sets of permissions. Plan the consent, not just the code. - Lookups are
FieldNameLookupId, notFieldName. Graph names the writable property differently from the column. Writing to the column name silently does nothing. Yes, silently. - Switching to
ValidateUpdateListItemneeds the list item id. Drive item ids don’t work, so$select=sharepointIdson the Graph item first and usesharepointIds.listItemId. - It’s
logonName, notloginName. Theensureuserparameter is spelled the unintuitive way, some docs get it wrong, and the wrong spelling gets you anInvalidClientQueryExceptionthat says nothing useful. - Chunk sizes are a multiple of 320 KiB or nothing. Graph upload sessions fail on the final commit, not on the offending chunk, so the error points at the wrong place.
- “Unsupported” is sometimes just undocumented. Multi-value lookups and person columns sat on my can’t-do list for far too long. Before you route an operation to the older API, check whether the modern one only lacks a doc page.
Wrapping Up
There’s no winner here. CSOM, SharePoint REST and Graph are three drawers in the same toolbox, and picking per operation beats picking per project.
My rule of thumb: Graph for files, CSOM for list items, SharePoint REST when nothing else can do it - and verify the write whenever you cross a boundary. The failure mode that actually hurt me wasn’t choosing the slower API. It was choosing the API that said yes and did nothing.
