# Graph Multi-Value Lookups: The LookupId Shape That Works

- Date: 2026-08-10
- Author: Jeppe Spanggaard
- Description: Learn how to write multi-value lookup and person columns on SharePoint list items with Microsoft Graph, using the LookupId array syntax the docs never show you.
- URL: https://jeppe-spanggaard.dk/blogs/graph-multi-value-lookup-person-fields/
- Tags: SharePoint, Microsoft Graph

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


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.

## The Shape

```http
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?**

1. The writable property is the column's internal name plus `LookupId`, not the column name. Graph [documents this for reads](https://learn.microsoft.com/en-us/graph/api/resources/fieldvalueset) and is silent about writes, but it's the same convention.
2. The value is a plain array of integer ids from the target list. No objects, no `LookupValue`, no wrapper.
3. The `@odata.type` annotation 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:

```json
{
  "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:

```http
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:

```http
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`:

```http
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.All` alone makes multi-value lookups read back as `[]`.** Populated column, empty array, no error. Add `Sites.Read.All` and 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`, not `Product Line`. Get them from `/lists/{id}/columns`.
- **`400` on the array means the column is single-value.** Check `lookup.allowMultipleValues` or `personOrGroup.allowMultipleSelection` on the column definition before blaming the syntax.
- **It's `logonName`, not `loginName`.** Get it wrong and `ensureuser` answers with `InvalidClientQueryException`, which tells you nothing about the spelling.
- **`ensureuser` needs 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 1000` on 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](/blogs/csom-vs-sharepoint-rest-vs-graph/) instead of committing a whole feature to one of them.

