I had multi-value lookup columns filed under “Graph can’t do that” for embarrassingly long. I was wrong. It can.
The syntax just looks nothing like what you’d write in CSOM, and the request that gets it wrong doesn’t tell you.
TL;DR
To write a multi-value lookup or a multi-value person column with Microsoft Graph,PATCH the <InternalName>LookupId property with a plain array of integer ids and annotate it: "ProductsLookupId@odata.type": "Collection(Edm.Int32)". The CSOM-style Collection(SP.FieldLookupValue) payload with a results wrapper gets you a 204 and an empty field. Person columns are lookups into the hidden User Information List, so they take the same shape - but the ids are site-collection user ids, and Graph has no EnsureUser to create one.The Shape
PATCH /sites/{site-id}/lists/{list-id}/items/{item-id}/fields
{
"ProductsLookupId@odata.type": "Collection(Edm.Int32)",
"ProductsLookupId": [6, 7, 8]
}
What’s happening here?
- The writable property is the column’s internal name plus
LookupId, not the column name. Graph documents this for reads and is silent about writes, but it’s the same convention. - The value is a plain array of integer ids from the target list. No objects, no
LookupValue, no wrapper. - The
@odata.typeannotation sits in a sibling property with the same name. Some report it isn’t strictly required. Include it anyway - it costs nothing and it’s the difference between a write and a shrug.
Creating an item works the same way, with the body nested under fields. Clearing the column is "ProductsLookupId": [].
The failure mode worth knowing: send the CSOM-flavoured Collection(SP.FieldLookupValue) with a results wrapper and you get 204 No Content and a column that stays empty. Nothing in the response suggests you did anything wrong.
Person Columns Are Just Lookups
A Person or Group column is a lookup into the hidden User Information List, so the write is identical:
{
"ReviewersLookupId@odata.type": "Collection(Edm.Int32)",
"ReviewersLookupId": [12, 13, 27]
}
Single-value version is { "ReviewerLookupId": "12" }.
The catch is where those numbers come from. They’re site-collection user ids, not Entra object ids and not Graph user ids. Graph has no /sites/{id}/users endpoint, so you read them out of the hidden list yourself:
GET /sites/{siteId}/lists?$filter=displayName eq 'User Information List'
GET /sites/{siteId}/lists/{uilId}/items?$expand=fields($select=id,EMail,Name,Title)
And then the real gap: if a user has never been referenced on that site, they have no entry in the list and therefore no id, and Graph offers no way to create one. That’s EnsureUser, and it only exists in SharePoint REST and CSOM:
POST https://{site}/_api/web/ensureuser
{ "logonName": "i:0#.f|membership|someone@the-tenant.example" }
So the write path for person columns can’t be pure Graph unless you can guarantee the users already exist on the site. In a backend I skip the dance and do the whole thing in CSOM with Web.EnsureUser() and a FieldUserValue. In a browser client I resolve or create with the REST call, then write the item with Graph.
Reading Them Back
Lookup fields aren’t returned by default, and the display value needs an explicit $select inside the $expand:
GET /sites/{site-id}/lists/{list-id}/items?$expand=fields($select=id,Title,ProductsLookupId,Products)
You get the id and the display value, and that’s it. Any other column from the target list is a second query. Twelve lookup fields per query is the documented ceiling.
Gotchas
Files.ReadWrite.Allalone makes multi-value lookups read back as[]. Populated column, empty array, no error. AddSites.Read.Alland the values appear. Single-value lookups are unaffected, which is what makes this one so slow to diagnose.- Internal names, not display names.
Product_x0020_Line, notProduct Line. Get them from/lists/{id}/columns. 400on the array means the column is single-value. Checklookup.allowMultipleValuesorpersonOrGroup.allowMultipleSelectionon the column definition before blaming the syntax.- It’s
logonName, notloginName. Get it wrong andensureuseranswers withInvalidClientQueryException, which tells you nothing about the spelling. ensureuserneeds a SharePoint-audience token.https://{tenant}.sharepoint.com/.default, not your Graph token. Two audiences, two sets of permissions to consent.- Old Graph SDK 5.x chokes on non-string
AdditionalData.CurrentDepth (1000) is equal to or larger than the maximum allowed depth of 1000on anything that isn’t a string, ints and arrays included. Fixed in current packages, so upgrade first and only reach for raw JSON through the request adapter if you’re pinned.
Wrapping Up
Multi-value lookups and person columns are writable from Graph. The rule is <InternalName>LookupId plus an integer array plus Collection(Edm.Int32), and the CSOM-shaped payload you’d expect to work is exactly the one that fails quietly.
The one thing Graph genuinely can’t do here is mint a user id that doesn’t exist yet. That’s still an EnsureUser call, and it’s part of why I keep a playbook for which SharePoint API I reach for instead of committing a whole feature to one of them.
