SharePoint REST API for Microsoft 365 Developers: When You Still Need It
Microsoft Graph is the default for SharePoint data, but it can't do everything. Here's when the SharePoint REST API is still the right call.
The SharePoint REST API isn’t dead. It still ships in every Microsoft 365 (M365) tenant, it still answers requests, and the endpoints you called five years ago still answer the same way today. Plenty of that old code has stopped running, but not because the API moved: it’s because the way you authenticate to it did. What has changed, beyond that, is that Microsoft would rather you didn’t think about it.
That’s not a criticism, exactly. It’s a product strategy, and it’s mostly the right one. For the better part of a decade the message has been consistent: Microsoft Graph is the front door to M365 data, SharePoint included. The docs lead with Graph. The samples lead with Graph. Every conference session on “getting at SharePoint data” opens with a Graph endpoint. If you came to this platform recently, you could be forgiven for assuming the SharePoint-specific API was quietly switched off at some point and nobody sent you the memo.
It wasn’t. And that’s where this gets interesting, because “Graph first” is guidance, not coverage. There’s a real, well-defined set of things you can do against a SharePoint site that Microsoft Graph simply doesn’t expose. If you don’t know where that boundary sits, you’ll either burn a day trying to make Graph do something it can’t, or you’ll reach for _api out of habit in a scenario where Graph would have saved you a pile of work.
Nearly everyone who lands on a page like this shows up with one of three questions. Is the SharePoint REST API dead? Should I use it for the thing I’m building right now? What can’t Graph do? Those are the questions I’m answering, in that order, and I’ll be specific about it.
A word on audience: this is for developers. If you’re building SharePoint Framework (SPFx) web parts, Azure Functions, background services, migration tooling, or anything else that reads and writes SharePoint data programmatically, you’re in the right place. If you’re an administrator looking for PowerShell to configure a tenant, this isn’t that article, although the API boundary discussion may still save you some time.
And here’s what this isn’t: a REST tutorial. This isn’t a reference for OData query syntax, so where a query shows up below it’s there to make a point about the API’s behavior, not to teach you the syntax. There’s plenty of that out there already, and I’ve written a fair bit of it. What you’ll get here is the decision. Which API to call, when to call it, and the honest reasoning behind each branch.
What the SharePoint REST API actually is
Every SharePoint site in your tenant exposes a REST endpoint at <site-url>/_api. That’s the whole surface. Append a path to it and you’re addressing a piece of that specific site:
_api/weband everything hanging off it: lists, folders, files, fields, users, role assignments_api/listsand_api/lists/getByTitle('Documents')/itemsfor list and library data_api/sitefor site collection level properties_api/search/queryfor the search service_api/SP.*for the assorted utility and service operations that don’t fit neatly into the object shape, likeSP.Utilities.UtilityandSP.AppContextSite
If those paths look less like a tidy resource-oriented API and more like an object graph you’re walking, that’s because that’s what they are. Understanding why makes the rest of this page make sense.
The _api endpoint arrived in SharePoint 2013 as a REST and OData facade over the client-side object model (CSOM). It wasn’t designed from a blank page as an HTTP API. It was designed to project the existing client object model over HTTP so browser code could reach it without a server-side proxy. That lineage is why _api/web/lists/getByTitle('Documents')/items reads like a chain of property and method calls: it is a chain of property and method calls, serialized into a URL. It’s also why you get OData conventions like $select, $filter, $expand, $top, and $orderby layered on top, and why how much metadata comes back is negotiated with an Accept header rather than being a fixed contract.
Now the part that matters most, and the reason this page is shaped the way it is: the SharePoint REST API has no global endpoint. There’s no single host you point at and then address any site in the tenant. Each site collection, and in fact each web inside it, has its own _api rooted at its own URL.
That single architectural fact drives most of the practical differences you’ll feel day to day:
- You need the URL before you can do anything. There’s no tenant-wide “give me every list in the organization” call. You resolve the site first, then you talk to it.
- Cross-site work multiplies. Touching twenty sites means twenty endpoints and twenty round trips, or twenty batches if you’re careful.
- The token audience is your tenant’s SharePoint resource, something like
https://contoso.sharepoint.com, not a shared global one. A token you acquired for Graph won’t work here, and the reverse is equally true. - Writes have their own ceremony. A
POSTfrom a page context needs a request digest in theX-RequestDigestheader, which you fetch from_api/contextinfoand which expires. Graph has no equivalent. Handling that plumbing for you is a large part of why libraries like PnPjs exist.
None of this is legacy plumbing you’re expected to route around, either. SPFx ships SPHttpClient specifically for calling _api from inside a page, digest and headers included. The framework Microsoft built for extending SharePoint assumes you’ll be calling the SharePoint REST API.
Microsoft Graph inverts every one of those characteristics. One host, one token audience, opaque site IDs instead of site URLs you have to know in advance. That inversion is exactly why Microsoft leads with Graph, and it’s worth being clear-eyed about how much it genuinely buys you before we get into what it costs.
Why Microsoft points you at Graph first
Let me be straight with you before I start arguing the other side: Microsoft’s guidance here is good guidance. If you’re starting a new integration today with no legacy constraints, Graph is the right default, and it isn’t close.
Start with what you feel on day one. Microsoft Graph is a single host, https://graph.microsoft.com/v1.0, fronting the entire M365 estate. You don’t resolve a site URL to figure out where to send the request. You address a site by ID, and the same base URL that gave you that site will also give you the user who owns it, that user’s manager, the Microsoft Teams team connected to it, and the files inside it. If your scenario touches anything outside SharePoint, and a surprising number do, the SharePoint REST API can’t help you at all. There is no version of _api that returns somebody’s calendar.
Then there’s identity. Graph gives you one resource, one token audience, and one permission vocabulary that applies across every workload. Scopes like Sites.Read.All, Files.ReadWrite.All, and User.Read are things a tenant admin has seen before and can reason about in a review. Compare that to walking a security team through a SharePoint app-only registration and you’ll appreciate the difference. Sites.Selected is the sharpest example: it lets an admin grant an application access to specific sites rather than every site in the tenant. That’s a real least-privilege story, and Graph’s tooling around it (Graph Explorer, granting per-site access through /sites/{site-id}/permissions, first-party SDK support) is what makes it one of the strongest arguments for Graph, even though the same grant also exists on the SharePoint resource.
Then there’s everything built around it. First-party Graph SDKs for the languages you’re most likely using. Graph Explorer for poking at an endpoint before you write a line of code. Consistent paging through @odata.nextLink. Delta queries when you need “what changed since last time” instead of re-reading the world. Change notifications so you’re not polling. Some exist in some form on the SharePoint side, but they’re less consistent and less likely to be what gets improved next.
Which brings up the structural point I think developers underweight: Graph is where the investment goes. When Microsoft ships a new M365 capability with an API attached, it shows up in Graph, and frequently it shows up only in Graph. Choosing the SharePoint REST API as your primary integration surface means betting on a stable API rather than a growing one. That can be exactly the right call, but it should be a deliberate call rather than muscle memory. If you want the full picture of what Graph covers and how to work with it, I wrote a companion guide: Microsoft Graph for Microsoft 365 developers.
So if Graph is one endpoint, one token, a better permission model, better tooling, and the place all the new work lands, why isn’t this article three paragraphs long?
Because “Graph first” and “Graph only” are different statements. Microsoft says the first one. A lot of developers hear the second. The distance between them is real, it’s specific, and it’s where the rest of this page lives.
When you still need the SharePoint REST API
Here’s the shape of the boundary, and once you see it you’ll predict most cases without looking anything up: Microsoft Graph is deep on SharePoint content and shallow on SharePoint configuration. Reading and writing what’s inside a site, its lists, its items, its files and folders, is well covered and improving. Changing the shape of that site, the schema of those lists, or the tenant settings above them is where coverage thins out, and that’s where _api is still the answer.
Content types are the clearest example of how far it has moved. They were the headline Graph gap for years and they aren’t one now. Graph models them at /sites/{site-id}/contentTypes and /sites/{site-id}/lists/{list-id}/contentTypes, with addCopy and addCopyFromContentTypeHub for pulling a published type down from the hub, publishing, column links, the document template, and an order property carrying both the position and which type is the default. The one thing the reference doesn’t let you set is the content type ID itself: you name a parent through base and the service assigns the ID. Start with Graph for content types. The exception is a small family of SPFx registration properties Graph has no concept of, three of them on content types and one on a field, which I’ll come back to in the SPFx section, because they’re the reason two of my most-read articles exist.
Creating and shaping list schema
Graph can create a list and add columns to it. POST /sites/{site-id}/lists accepts a columns collection, and /sites/{site-id}/lists/{list-id}/columns adds more later. For the everyday types, text, number, choice, date, person, lookup, boolean, that’s genuinely enough, and you should use it.
Where it runs out is anything the columnDefinition resource doesn’t model, and Microsoft says so in its own reference: the common field types are represented, but the API is still missing some, and when you hit one you get a column back with none of its type facets populated. Rating scales, task outcomes, and attachments are all in that category. Even for types Graph does model you lose fidelity, since a CAML <Field /> carries attributes the matching Graph facet has no property for. The SharePoint REST API has an escape hatch with no Graph equivalent: POST _api/web/lists(guid'{list-id}')/fields/createfieldasxml, which takes a raw <Field /> element inside an SP.XmlSchemaFieldCreationInformation parameter. Every attribute the field schema supports is available to you there. You may know this operation by its CSOM name, AddFieldAsXml.
Views are the larger omission, and the one that bites provisioning code hardest, because a view is how a list actually looks to a human being. Creating one, setting its ViewFields, its CAML ViewQuery, its row limit, or making it the default all runs through _api/web/lists(guid'{list-id}')/views. Same story for the settings that always turn up at the end of a provisioning script: versioning and draft visibility, content approval, require-check-out, and breaking permission inheritance to assign specific SharePoint role definitions and site groups on the list itself.
Permissions deserve spelling out, because Graph looks closer here than it is. There is no break-inheritance operation in Graph at all. If the list is a document library you can share its root with the invite action on the corresponding driveItem, and sharing an item that was inheriting creates a unique permission scope as a side effect, with retainInheritedPermissions deciding whether the copied-down grants survive it. That’s sharing, not permission-model control. Graph won’t hand you SharePoint’s role definitions and site groups, which is what provisioning code is actually trying to set, and Graph’s own beta list permissions API says the quiet part out loud: you can create an application permission on a list with it, not a permission for a user.
What to call instead: Graph for creating the list and its ordinary columns, _api/web/lists(...)/fields and .../views for everything past that, and breakroleinheritance plus roleassignments when the grant has to name a SharePoint group.
Provisioning sites and the furniture inside them
In v1.0, Graph creates SharePoint sites only sideways. POST /groups provisions an M365 group and gets you a team site as a consequence, which is fine when a group-connected team site is what you wanted and no help at all when you wanted a communication site or a site with no group attached. This is also the gap I’d bet on closing next, and it’s already half closed: Graph beta grew a POST /sites at the end of 2025 that creates a site collection directly, takes a sitepagepublishing or sts template, hands back a 202 you poll for completion, and comes with a Sites.Create.All permission that scopes the caller to the site it just made. That’s a better permission story than the SharePoint path, which wants tenant-admin-grade access. Until it reaches v1.0, though, production site creation is _api/SPSiteManager/create. Subwebs are _api/web/webs/add either way, because Graph has no subsite creation API in either version.
The furniture is the more durable gap, and it’s the part people underestimate until they’re two days into a provisioning project. Navigation nodes on quick launch and the top nav. User custom actions, which is how an SPFx application customizer gets attached to a site or a list. Feature activation. Theming. Tenant and site storage entities, the key-value properties a lot of SPFx solutions read their configuration from. None of that is Graph territory, which is a large part of why the PnP provisioning tooling is still built on CSOM and REST.
What to call instead: POST /groups in Graph when a group-connected team site is genuinely what you want, _api/SPSiteManager/create, _api/web/webs/add, _api/web/navigation, and _api/web/UserCustomActions for everything else.
Managed metadata beyond the term store
Graph got a real taxonomy API and it’s decent. GET /sites/{site-id}/termStore gives you groups, sets, terms, relations, and children in v1.0, so building or reading a term store no longer forces you off Graph. That gap closed.
Two halves didn’t close, and they’re worth keeping separate. You can’t create a managed metadata column through Graph, and you can’t reliably write a term value into one. A managed metadata column isn’t a simple value, it’s a composite that also writes to a hidden note field, and Graph’s list item API has no concept of it. There’s a workaround circulating that PATCHes the hidden note field directly by its internal name, and I’d steer you off it: the reported failure mode isn’t a clean error, it’s a first write that looks like it succeeded, later writes that silently do nothing, and neighboring fields on the item getting cleared. That’s a data-integrity problem, not an ergonomics one. The SharePoint side has a purpose-built shape, SP.Taxonomy.TaxonomyFieldValue. If your scenario is “tag ten thousand documents with terms,” budget for that being a REST job even though the term store itself is available in Graph.
What to call instead: Graph /termStore for reading and managing the taxonomy, _api for creating taxonomy columns and for writing term values onto items with TaxonomyFieldValue.
Tenant-scoped SharePoint administration
Some operations belong to the tenant rather than to any one site, and this is the thinnest part of Graph’s SharePoint story. The one that matters most to the people reading this: deploying an SPFx solution package. Uploading a .sppkg to the app catalog and deploying it is _api/web/tenantappcatalog/Add(...) followed by AvailableApps/GetById('{id}')/Deploy, sent to the app catalog site’s own URL rather than whatever site you had handy. There is no Graph equivalent, in v1.0 or in beta. If you have a release pipeline that ships SPFx, that pipeline is already calling the SharePoint REST API, whether or not anyone on your team thought of it that way.
Site designs and site scripts live here too, through _api/Microsoft.SharePoint.Utilities.WebTemplateExtensions.SiteScriptUtility.CreateSiteScript(...), which needs tenant-admin rights but not the admin host. Then there’s the long list of tenant configuration switches. Graph has /admin/sharepoint/settings, which is worth checking first and covers around thirty properties: sharing controls, storage quotas, site creation defaults, idle session sign-out. For scale, the CLI for Microsoft 365 sets more than ninety against the same tenant. Past Graph’s slice you’re in awkward territory rather than clean REST territory. There is an _api/Microsoft.Online.SharePoint.TenantAdministration.Tenant namespace on the admin host and it works, but Microsoft has never documented it, which is why the mainstream tools ship CSOM XML to _vti_bin/client.svc/ProcessQuery instead.
What to call instead: Graph /admin/sharepoint/settings for the settings it covers, _api/web/tenantappcatalog/... on the app catalog site for solution deployment, SiteScriptUtility for site scripts and designs, and CSOM over ProcessQuery for the tenant switches nobody ever documented a REST path for.
Search, user profiles, and social features
Three more areas deserve a look before you assume Graph has you covered, and each is a different shape of “partly.”
Search is the closest call. Graph’s search API does do refiners, under the name aggregations, so don’t write it off on that basis. What it lacks is result sources and ranking models, and _api/search/query has both. Microsoft’s own search overview adds a wrinkle: SharePoint search customizations like a custom search schema or custom result sources can interfere with Microsoft Search operations, so a heavily customized setup isn’t merely unsupported in Graph, it can degrade what Graph gives you.
SharePoint user profile properties are a firm gap. The SPS- prefixed ones, and any custom properties your organization added, aren’t part of the Microsoft Entra ID user object and aren’t reachable from Graph at all. Its beta /me/profile is a different store entirely, the people-card data. For the user profile service you’re calling _api/SP.UserProfiles.PeopleManager.
Following is the one that surprises people. Graph v1.0 follows and unfollows sites now, and following documents is there in beta, so the endpoints exist. But Graph’s own known-issues page says following state drifts out of sync with SharePoint’s and tells you to use the SharePoint following REST API instead.
App-only authentication for the SharePoint REST API
Before you build a background job, a provisioning pipeline, or a tenant-admin task around the SharePoint REST API, settle how it authenticates first, since that’s what stops you on day one rather than day ten. The Azure Access Control Service (ACS) app-only path that SharePoint developers leaned on for a decade, registering a principal at /_layouts/15/appregnew.aspx and calling with a realm and a client secret, is gone. It was fully retired on April 2, 2026, alongside the SharePoint Add-in model, with no option to extend.
App-only callers now need a Microsoft Entra ID application with app-only permissions granted on the SharePoint resource, which Entra’s permission picker labels SharePoint and the underlying service principal calls Office 365 SharePoint Online. Note the credential: a certificate, not a client secret. That isn’t a recommendation, it’s the only thing that works, because SharePoint Online blocks every other app-only credential type with an access denied. And watch which resource you’re granting on, because Sites.Selected exists on both the SharePoint resource and on Graph, and they are two separate grants that happen to share a name. Consenting to either one on its own still gets you nothing until an administrator grants the application access to a specific site.
Where CSOM still comes in
CSOM is still the last stop for a small and shrinking set of operations that never got a REST endpoint at all. It’s a legitimate tool, and I won’t tell you never to touch it, but when both are available I’ll take REST every time, for reasons I laid out years ago in why I prefer REST over CSOM. That argument has aged well.
Calling it from SPFx
SPFx is where most people reading this will actually type the code, and where the decision gets easiest, because the framework hands you a different HTTP client per destination. Picking the client is the decision.
| Client | Talks to | Token handling |
|---|---|---|
SPHttpClient | SharePoint _api in the current site | Implicit, SharePoint context |
AadHttpClient | Your own Entra-secured API | SPFx acquires it |
MSGraphClientV3 | Microsoft Graph | SPFx acquires it |
SPHttpClient is the one developers forget is special. It’s a fetch wrapper that already knows who you are, because the page it’s running in is a SharePoint page: no token acquisition in your code, no consent prompt for the user. It also handles the _api ceremony from earlier, the OData version and Accept headers and the X-RequestDigest on writes. You get it from this.context.spHttpClient and pass SPHttpClient.configurations.v1 on every call. The current site is the common case rather than a hard limit: another site collection on the same tenant.sharepoint.com host works too, subject to the user’s permissions there. The OneDrive host, tenant-my.sharepoint.com, is a different origin, so it doesn’t come along for free.
What deserves more attention is what SPHttpClient means for permissions: your call runs as the signed-in user with exactly the SharePoint permissions that user already has. There’s nothing to declare in package-solution.json and nothing for an administrator to approve. If the user can’t see the list in the browser, your request fails the same way, which is almost always the behavior you want.
AadHttpClient and MSGraphClientV3 go through SPFx’s permission request flow instead: you declare webApiPermissionRequests in package-solution.json, and after the package is deployed an administrator approves the request on the API access page in the SharePoint admin center. Budget for a Global Administrator, not just any admin, because approving Microsoft Graph or any other Microsoft API needs one. Then here’s the catch nobody puts on a slide: those permissions aren’t granted to your solution. They go to a single shared tenant-wide identity, the SharePoint Online Client Extensibility Web Application Principal, that every SPFx component in the tenant runs under. Approve Mail.Read so one web part can show a user’s inbox, and every other SPFx component in that tenant can read mail too.
Domain-isolated web parts used to be the escape hatch, giving a solution its own Entra ID application instead of the shared principal. Microsoft retired them in April 2026 with no replacement, so that blast radius is the only model on offer. Use MSGraphClientV3 when you need Graph, since it beats hand-rolling MSAL in a web part, but ask for the narrowest scope that does the job, and remember SPHttpClient sidesteps all of it for current-site SharePoint data. A fourth client, plain HttpClient, covers anonymous or third-party endpoints that need no M365 token at all.
Two places where this stops being abstract, and they’re my favorite examples because Graph has no concept of either. An SPFx field customizer isn’t installed onto a column so much as the column is pointed at it: you set the field’s ClientSideComponentId to the component’s GUID with a MERGE against _api/web/lists(guid'{list-id}')/fields(guid'{field-id}'), walked through in registering SPFx field customizers with the REST API. List form customizers work the same way one level up, on the list’s content type, through NewFormClientSideComponentId, EditFormClientSideComponentId, and DisplayFormClientSideComponentId, covered in registering SPFx list form customizers with the REST API.
Those properties are the SPFx registration binding Graph doesn’t know about. Microsoft’s own extensibility model for SharePoint is registered through the SharePoint REST API, which should settle any lingering doubt about whether the API is on its way out.
PnPjs: what most people should actually type
Everything above frames this as REST versus Graph, which leaves an obvious question hanging: where does PnPjs fit? The answer is that it isn’t a third option. PnPjs wraps the SharePoint REST API. Choosing PnPjs is choosing REST. You’re just choosing it with the digest handling, the header negotiation, the paging, the batching, and the typing already solved by somebody else.
That’s worth being explicit about, because I’ve watched developers treat “should I use PnPjs or REST?” as a real fork in the road. It isn’t. Every sp.web.lists... call you write becomes an HTTP request against _api on the wire, and you can watch it happen in your browser’s network tab. The parity gaps above apply identically whether you call _api yourself or let PnPjs call it for you.
What you get for the dependency is ergonomics, and the difference isn’t subtle:
1// Raw: build the URL, then unwrap the OData envelope yourself.
2const url = `${this.context.pageContext.web.absoluteUrl}`
3 + `/_api/web/lists/getByTitle('Documents')/items?$select=Title,Modified&$top=10`;
4const response = await this.context.spHttpClient.get(url, SPHttpClient.configurations.v1);
5const rawItems = (await response.json()).value;
6
7// PnPjs: the same HTTP request, composed instead of concatenated.
8const sp = spfi().using(SPFx(this.context));
9const pnpItems = await sp.web.lists.getByTitle('Documents').items
10 .select('Title', 'Modified')
11 .top(10)();Both halves produce the same request. The second one won’t silently break because you forgot to encode a quote inside a $filter, and it’s typed, so your editor tells you what’s available instead of you guessing at property names. That spfi().using(SPFx(this.context)) line is how you hand PnPjs your SPFx context so it authenticates the way SPHttpClient would, and there are equivalent behaviors for Node and plain-browser scenarios outside SPFx. One thing that catches people on their first run: PnPjs uses selective imports to keep your bundle from carrying the whole library, so those fluent members only exist once you’ve pulled in the pieces you’re touching, which for the snippet above means @pnp/sp/webs, @pnp/sp/lists, and @pnp/sp/items. Skip them and you get getByTitle is not a function at runtime rather than a helpful compile error, because TypeScript sees a type-only import and drops it.
Two more reasons it’s usually the right default. Batching, which is a meaningful performance difference the moment you’re issuing dozens of calls, and which is genuinely unpleasant to hand-roll against SharePoint’s multipart batch format. And an escape hatch: when PnPjs doesn’t wrap the endpoint you need, you compose an SPQueryable and call it with spGet or spPost rather than abandoning the library, so an unwrapped endpoint costs you a few lines instead of a rewrite.
So my default recommendation, if you’re in SPFx or in Node and you’ve decided the SharePoint REST API is the right target, is PnPjs. Reach for SPHttpClient directly when you’re calling something PnPjs doesn’t cover, when you’re debugging and want to see the exact request on the wire, or when adding a dependency is a bigger deal in your project than the ergonomics are worth.
The mechanics that still bite
Everything to this point has been about whether to call the SharePoint REST API. The rest of this page assumes you’ve decided to, and covers what the API does to you once you’re committed. PnPjs hides most of the plumbing, but it can’t change how the service behaves, so what follows applies whether you’re composing fluent calls or assembling requests by hand.
Three of these four have been true for as long as most people have been calling _api, and they’re still true in SharePoint Online (SPO) today. The fourth, the trailing semicolon, is a story about how opaque this API’s failures get, and it’s also the only one here that Microsoft eventually fixed, so I’ll be explicit about where it stands. That contrast is the point: the other three aren’t bugs anyone is going to fix out from under you, they’re consequences of how the API is built.
Lookup columns don’t filter the way you expect
Add a lookup column named Employer to a list, then query an item over REST. The value you want isn’t there under that name. What comes back is EmployerId, an integer pointing at an item in the parent list, because SharePoint splits a single-value lookup into two fields. So $filter=Employer eq 'Contoso' doesn’t do what you meant: there’s no Employer property holding a string to compare against.
You walk the association explicitly instead, expanding the navigation property and filtering through it:
1_api/web/lists/getbytitle('Employees')/items
2 ?$select=Title,Employer/Id,Employer/Title
3 &$expand=Employer
4 &$filter=Employer/Id eq 1Filter through the ID, the way that query does. That’s the route Microsoft’s OData guidance documents and the walkthrough below takes. Two rules stop you before syntax does: a view or query crosses at most twelve lookup columns in SharePoint Online, and unlike on-premises you can’t raise that ceiling; and on a list of any size, “cannot be referenced in filter or orderby as it is not indexed” arrives long before a parser complaint.
Two consequences catch people who think this only affects lookups. Person and group columns are lookups under the covers, pointed at the hidden User Information List, so “every item assigned to Andrew” means resolving Andrew to an ID first with _api/web/siteusers or ensureuser. Managed metadata columns are lookups too, into a hidden TaxonomyHiddenList at the root of the site collection, which is why a term value arrives as a composite of label, GUID, and WssId rather than a string, and why writing one is the awkward operation I flagged earlier. That WssId reads -1 until the term has been used in the site collection once. If this is wearing you down, RenderListDataAsStream is Microsoft’s documented answer: it reads lookup and managed metadata fields and takes lookup ID filters as first-class parameters. The rest, including the multi-value lookups you can’t filter at all and the operators $filter won’t take, is in filtering on lookup fields.
The metadata mode you ask for changes the size of every response
How much OData metadata SharePoint sends back is negotiated in the Accept header, and the mode a decade of samples taught everyone is the most expensive one. application/json;odata=verbose wraps the whole response in a d envelope, decorates every entity with a __metadata block carrying its type name, URI, and etag, and emits a __deferred stub with a full URL for every navigation property you didn’t expand.
Microsoft published the numbers when they shipped the alternatives, and the spread is wider than people expect. The same query came back at 46,647 bytes under verbose, 11,173 under minimalmetadata, and 6,832 under nometadata. Verbose costs you close to seven times the payload, almost all of it scaffolding you’re about to throw away.
application/json;odata=nometadata drops that scaffolding: no envelope, no __metadata, no deferred stubs, just value with your fields in it. odata=minimalmetadata sits between the two, keeps type names without the deferred links, and is what you get when you don’t ask for anything. Verbose also drags a requirement into your writes: a POST or MERGE body has to carry a __metadata object naming the list item entity type. Don’t guess at that name. It’s per-list and the pattern has exceptions, so the library you see as “Documents” is usually internally “Shared Documents” and wants SP.Data.Shared_x0020_DocumentsItem rather than the SP.Data.DocumentsListItem you’d have typed. Ask the list: _api/web/lists/getbytitle('Documents')?$select=ListItemEntityTypeFullName. Drop to nometadata and the requirement goes away entirely.
So ask for nometadata deliberately unless something downstream needs the type names, with one caveat: not every endpoint honors it. PnPjs, incidentally, doesn’t set any of this. It sends a plain Accept: application/json, takes the minimalmetadata default, and normalizes whatever shape comes back, which is why its results look tidy without you thinking about it.
A trailing semicolon that cost people real time
This is my favorite one, because the failure was completely opaque. Send Accept: application/json;odata=verbose; with that last semicolon dangling and you’d get back an HTTP 400 with nothing useful in the body to explain it. Same URL, same token, same everything, minus one character, and it worked. I traced it down years ago after readers of a course started hitting it on a build I couldn’t reproduce, and it turned out to have nothing to do with their code: a semicolon can produce an HTTP 400 all on its own.
Let me be straight about where that stands, because this page is supposed to tell you what’s true now. It was a SharePoint 2013 SP1-era regression, it went away in a later cumulative update, and I’ve found nothing suggesting it reproduces against SharePoint Online today. Two takeaways survive it regardless, and the second is the durable one. Never write the trailing semicolon: it buys you nothing and the OData spec doesn’t ask for it. And more useful day to day, SharePoint’s header parsing is stricter than its documentation implies, so when _api hands you a 400 with an unhelpful body, audit your headers before you rewrite your query. Accept, Content-Type, and X-HTTP-Method are where the strange ones live.
$metadata is there, and it isn’t what you think it is
The $metadata operator is what makes an OData service self-describing, and SharePoint shipped without it before quietly turning it on later, which I was fairly pleased about at the time when I wrote up the $metadata operator arriving. It’s still there at _api/$metadata, it still returns a CSDL document, and most developers have never opened it. It’s worth opening once, if only for the line that says DataServiceVersion="3.0", which answers a question you may not have known you had: _api is OData v3. That’s why the metadata mode is spelled odata=verbose rather than v4’s odata.metadata=none, and why pasting a v4 header idiom in here gets you nowhere.
What it won’t do is settle the two questions people go there to settle. It’s the static service model, roughly seven hundred types like SP.Web, SP.List, and the generic SP.ListItem. The per-list SP.Data.* entity type that a verbose write demands isn’t in it, because that type is generated per list, and neither are your columns’ internal names. Both come from the list itself: ?$select=ListItemEntityTypeFullName for the type, and _api/web/lists/getbytitle('X')/fields?$select=Title,InternalName for the names.
Go get those internal names, because they aren’t the display names you see in the browser. Create a column called “Order Number” through the UI and its internal name is Order_x0020_Number forever, even if somebody renames it later. Guessing at that is how you end up filtering on a property that doesn’t exist.
Batching
The chattiness problem is real and it shows up fast. Creating fifty list items over _api is fifty HTTP requests, each paying full latency to SPO, which on a slow connection is the difference between a web part that feels instant and one that feels broken. Batching is the fix, and it’s the single largest performance lever the SharePoint REST API gives you.
A batch is a POST to _api/$batch with a Content-Type of multipart/mixed and a boundary token separating the parts. Each part is a complete HTTP request, verb and URL and headers, wrapped in MIME headers so the server can unpack it. Reads sit directly in the batch. Writes can’t: anything that isn’t a GET goes inside a changeset, a nested multipart block with its own boundary inside the outer batch.
1POST https://tenant.sharepoint.com/sites/hr/_api/$batch HTTP/1.1
2Content-Type: multipart/mixed; boundary=batch_<guid>
3
4--batch_<guid>
5Content-Type: application/http
6Content-Transfer-Encoding: binary
7
8GET https://tenant.sharepoint.com/sites/hr/_api/web/lists/getbytitle('Drivers')/items HTTP/1.1
9Accept: application/json;odata=nometadata
10
11--batch_<guid>
12Content-Type: multipart/mixed; boundary=changeset_<guid>
13
14--changeset_<guid>
15Content-Type: application/http
16Content-Transfer-Encoding: binary
17
18POST https://tenant.sharepoint.com/sites/hr/_api/web/lists/getbytitle('Drivers')/items HTTP/1.1
19Content-Type: application/json;odata=nometadata
20
21{"Title":"Fernando Alonso","Team":"Ferrari"}
22--changeset_<guid>--
23--batch_<guid>--Here’s where SharePoint diverges from what the OData spec led you to expect. In OData a changeset is atomic: everything inside succeeds, or it all rolls back. SharePoint isn’t transactional, and Microsoft says so plainly: if one operation in a changeset fails, the others still complete and nothing rolls back. Several writes can share one changeset; it’s a grouping construct, not a transaction. Write your own compensating logic if partial application would corrupt something.
Ordering is the other thing to get right. Queries and changesets execute in the order you list them, but requests within a changeset carry no ordering guarantee, so if step two depends on step one they belong in separate changesets. And if step two needs the ID step one created, don’t reach for OData’s Content-ID referencing: the spec describes it, but nothing says SharePoint implements it and PnPjs never emits the header. Read the ID back and send a second batch.
The response comes back as multipart too, and you parse it yourself. SharePoint doesn’t echo the changeset boundaries the way the spec describes, so matching responses to requests is positional: they come back in the order they were processed. I picked all of this apart, payload by payload, in understanding batching requests and exploring batch payloads, responses, and changesets, which is also the best argument I know for letting PnPjs do it for you.
Some limits worth planning around. A batch is scoped to the site you posted it to, so cross-site work is one batch per site, and those URLs are case-sensitive. Uploading multiple files in one batch isn’t supported. As for how many requests fit, I’d stop repeating the ceiling of 100 you’ll see quoted around the web, mine included: Microsoft documents no request limit for _api/$batch at all. What’s real is that the libraries each picked their own number, PnP’s .NET and PowerShell tooling at 100 and PnPjs at 20, and that the PnP tooling assumes a payload ceiling around a megabyte rather than a request count. Chunk your bulk job. And don’t expect a batch to buy you headroom: the server still does all the work you asked for, and SharePoint counts batched sub-requests individually against your throttling budget.
Throttling
You will get throttled. Not “if your code is bad,” just eventually, because SPO is a shared service and throttling is how it protects itself from every tenant’s background jobs at once. Microsoft publishes a resource-unit model now, with per-user, per-tenant, and per-app ceilings that scale with your license count. Read it, then read the caveat: CSOM and REST have no predetermined resource-unit cost, they burn more units per call than the equivalent Graph request, and Microsoft says outright they may be throttled beyond the documented limits. Build a resilient access pattern rather than a tuned request rate.
What you’ll see is an HTTP 429, occasionally a 503, with a Retry-After header giving a wait in seconds. Honor it exactly, no sooner and no later. Throttled requests still count against your usage, so ignoring Retry-After digs the hole deeper. Add exponential backoff with jitter for the responses that arrive without the header, and so a fleet of workers throttled together doesn’t all come back in the same second and throttle each other again. Treating a 429 as a transient error and hammering the endpoint is how a job goes from throttled to blocked.
Newer than most of the advice you’ll find, and worth wiring up: SPO emits RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers once an app passes roughly eighty percent of its per-minute budget. That’s a chance to slow down before the 429 rather than after it.
Decorate your traffic. SPO reads the User-Agent header, and traffic that identifies itself in the documented format, ISV|CompanyName|AppName/Version for a product you ship, NONISV|CompanyName|AppName/Version for something internal, gets prioritized over undecorated traffic when the service decides whose requests to shed. Prioritization is Microsoft’s word, not a promise of a higher ceiling. Sending your app ID and title is the other half. Browser code is exempt, since browsers won’t let you overwrite the user agent, but server-side it’s one header and the cheapest item on this list.
The rest is access-pattern discipline, and it’s where the real wins are. Batch, so fifty operations are a handful of round trips. $select the columns you need instead of taking the default projection, which on a wide list means dragging back dozens of fields to read two. Page with $top and follow the next link rather than asking for everything. Don’t $expand a lookup you aren’t going to read. Cache anything that doesn’t change often. And run bulk work on a single spread-out worker instead of parallelizing hard, because twenty threads against one site collection is the fastest way to find the limit you couldn’t price in advance.
Where this is going
Let me answer the first of those three questions straight, since it’s the one that brings most people here. The SharePoint REST API is not deprecated. There’s no retirement notice for _api, no announced sunset, no banner on the docs telling you to migrate by a date. And it isn’t that Microsoft has gone quiet about retirements: they killed Azure ACS outright, named and dated years ahead. _api has never gotten that treatment.
What it is, is static. New capability lands in Graph. When an M365 feature ships with an API attached, that API is a Graph resource, and the SharePoint REST equivalent either shows up later or never shows up at all. Look at the gaps that closed: content types and the term store became reachable from Graph, not from some new corner of _api, and the granular permission model grew on the Graph side too, with Sites.Selected joined by list, item, and file scoped equivalents. Nobody is extending the SharePoint REST API to compete with Graph. It’s the substrate under a product that Graph increasingly fronts.
“Supported but static” is a perfectly good place for an API to be, and I don’t mean it as a warning. A surface that stopped moving is worth something to whoever maintains your provisioning pipeline in 2029: nobody is going to change the shape of createfieldasxml out from under you.
Which gives you the second answer, the one about the thing you’re building right now: the SharePoint REST API is safe where it’s the only option, and a poor default for anything Graph covers. Reaching for _api out of habit in a scenario Graph handles cleanly means you’ve signed up for site-by-site endpoints, a second token audience, request digests, and no delta queries, in exchange for nothing. But the more expensive mistake, and the one I see more often, runs the other way: a team won’t touch REST because it feels legacy, and burns two days trying to make Graph create a list view.
The third answer, what Graph can’t do, is the boundary this page has been tracing: Graph is deep on SharePoint content and shallow on SharePoint configuration. That boundary only moves in one direction. Every gap that closes, closes toward Graph. None of them close back.
So plan for the direction of travel without paying for it today. Keep your SharePoint data access behind a thin layer of your own instead of sprinkling _api URLs through fifteen components, and the day Graph grows a list-view API you change one file rather than auditing a codebase. Don’t rewrite working REST code chasing a Graph endpoint that does the same job. Do check the boundary again before you start something new, because it will have moved a little since the last time you looked.
What’s next
Where you go from here depends on what pushed you onto this page.
If you landed here because Graph couldn’t do the thing you needed, the next useful step is knowing the rest of what it can do, and I wrote the companion guide for exactly that: Microsoft Graph for Microsoft 365 developers. It covers the authentication model, the SDKs, delta queries, change notifications, and batching on the Graph side. A good number of the “should I use REST for this?” questions I get turn out to be “I didn’t know Graph could do that” questions.
If you’re writing this code inside a web part or an extension and SPFx itself is still fuzzy, start with What is the SharePoint Framework (SPFx)?, then come back to the client table above. Picking the HTTP client is the decision in SPFx, and that lands better once you know what the framework is doing for you.
I cover the SPFx data-access story end to end in my Mastering the SharePoint Framework course.
And if you’re standing up app-only access for a background job, a provisioning pipeline, or anything else that runs without a signed-in user, the real work happens in Entra ID: the application registration, the certificate credential, and the permission grant on the SharePoint resource. Microsoft Entra ID for Microsoft 365 developers is where that starts making sense.
Where has Graph sent you back to _api lately? Tell me in the comments. The gaps people are actually hitting are the first thing I check when I come back to refresh this page.