# CSOM vs SharePoint REST vs Graph: My Pick-One Playbook

- Date: 2026-07-30
- Author: Jeppe Spanggaard
- Description: Learn when to use CSOM, SharePoint REST or Microsoft Graph for SharePoint work, and which operations each one silently refuses to do.
- URL: https://jeppe-spanggaard.dk/blogs/csom-vs-sharepoint-rest-vs-graph/
- Tags: SharePoint, Microsoft Graph, CSOM

## TL;DR

My split between CSOM, SharePoint REST and Microsoft Graph: CSOM for list item CRUD (CAML joins, 100+ operations per round trip, and a way past the 5,000 item list view threshold), Graph for files (upload sessions, fewer requests, faster), and SharePoint REST only when it's the only thing that works. The trap is metadata: Graph writes managed metadata columns with a `200 OK` and no value at all, and person columns only if the user already has an id in the site's User Information List, since Graph has no `EnsureUser`. Both gaps are filled by SharePoint REST or CSOM.


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](https://learn.microsoft.com/en-us/graph/api/listitem-update) 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](/blogs/pnp-core-vs-pnp-framework-migration-blockers/), 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](/blogs/csom-performance-optimization-chunking/) is the reliable sweet spot. Graph's `$batch` caps at 20 requests, and [a failed batch needs picking apart](/blogs/graph-batch-smart-retry/) 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](/blogs/sharepoint-csom-performance-playbook/), and the join pattern has [its own post](/blogs/joining-multiple-lists-csom-caml/).

## 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.

```typescript
// 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](/blogs/download-multiple-files-from-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](/blogs/graph-batching-file-content-mapping/).

## 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's `MenuState`/`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 `_api` calls 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 no `EnsureUser` and no `/sites/{id}/users` endpoint 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 `200` is 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 of `https://<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`, not `FieldName`.** Graph names the writable property differently from the column. Writing to the column name silently does nothing. Yes, silently.
- **Switching to `ValidateUpdateListItem` needs the list item id.** Drive item ids don't work, so `$select=sharepointIds` on the Graph item first and use `sharepointIds.listItemId`.
- **It's `logonName`, not `loginName`.** The `ensureuser` parameter is spelled the unintuitive way, some docs get it wrong, and the wrong spelling gets you an `InvalidClientQueryException` that 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.

