Managed metadata fields are one of the most misunderstood parts of the SharePoint REST API. The data comes back in an unexpected shape, saving requires a special wire format, and the documentation gives you just enough information to get it wrong.
This article is a focused reference: how to read managed metadata field values from list items, how to write them back correctly, and how to handle multi-value taxonomy fields β all via PnPjs v3.
πΊοΈ How Managed Metadata Fields Are Stored
A managed metadata (taxonomy) field stores two things per item:
- A hidden note field (
fieldname_0) that holds the full term value in wire format - A lookup field (
fieldname) that holds a WssId (a site-local integer) and the term label
When you read a list item, the managed metadata column comes back as an object:
{
"Department": {
"Label": "Engineering",
"TermGuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"WssId": 4
}
}
For multi-value taxonomy fields, the value is an array of these objects.
π Reading a Single Managed Metadata Field
import { SPFI } from '@pnp/sp';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
export interface IProjectItem {
Id: number;
Title: string;
DepartmentLabel: string;
DepartmentTermGuid: string;
}
export async function getProjectItems(sp: SPFI): Promise<IProjectItem[]> {
const items = await sp.web.lists
.getByTitle('Projects')
.items
.select('Id', 'Title', 'Department')
.expand('Department') // Required to get the full taxonomy object
.top(50)();
return items.map(item => ({
Id: item.Id,
Title: item.Title,
DepartmentLabel: item.Department?.Label ?? '',
DepartmentTermGuid: item.Department?.TermGuid ?? ''
}));
}
Always use .expand('FieldName') when selecting a managed metadata field β without it, SharePoint returns only the WssId integer, not the label and GUID.
π Reading a Multi-Value Managed Metadata Field
Multi-value taxonomy fields return an array. The structure is the same, but wrapped in a results property when using the legacy REST endpoint:
export interface ITaggedDocument {
Id: number;
Title: string;
Tags: Array<{ Label: string; TermGuid: string }>;
}
export async function getTaggedDocuments(sp: SPFI): Promise<ITaggedDocument[]> {
const items = await sp.web.lists
.getByTitle('Documents')
.items
.select('Id', 'Title', 'Tags')
.expand('Tags')
.top(100)();
return items.map(item => {
// PnPjs v3 normalises the response β multi-value fields come as an array
const tags = Array.isArray(item.Tags)
? item.Tags
: item.Tags?.results ?? [];
return {
Id: item.Id,
Title: item.Title,
Tags: tags.map((t: any) => ({
Label: t.Label,
TermGuid: t.TermGuid
}))
};
});
}
βοΈ Writing a Single Managed Metadata Field
The safest way to write a managed metadata field is via validateUpdateListItem. This method accepts the wire format string and handles WssId resolution server-side β you never need to look up or manage WssIds yourself.
Wire format: -1;#<term label>|<term GUID>
export async function setDepartment(
sp: SPFI,
itemId: number,
termLabel: string,
termGuid: string
): Promise<void> {
// The -1 WssId tells SharePoint to resolve the real WssId from the term GUID
const wireValue = `-1;#${termLabel}|${termGuid}`;
await sp.web.lists
.getByTitle('Projects')
.items
.getById(itemId)
.validateUpdateListItem([
{
FieldName: 'Department', // Internal field name (not display name)
FieldValue: wireValue
}
]);
}
Do not use .update() for managed metadata fields. The standard item update endpoint does not handle WssId resolution β you end up with a -1;# value permanently saved, which breaks list view rendering and search.
βοΈ Writing a Multi-Value Managed Metadata Field
For multi-value fields, concatenate multiple wire values separated by ;#:
export async function setDocumentTags(
sp: SPFI,
itemId: number,
tags: Array<{ label: string; guid: string }>
): Promise<void> {
// Multi-value format: -1;#Label1|GUID1;#-1;#Label2|GUID2
const wireValue = tags
.map(t => `-1;#${t.label}|${t.guid}`)
.join(';#');
await sp.web.lists
.getByTitle('Documents')
.items
.getById(itemId)
.validateUpdateListItem([
{
FieldName: 'Tags',
FieldValue: wireValue
}
]);
}
To clear all tags from a multi-value field, pass an empty string:
await sp.web.lists
.getByTitle('Documents')
.items
.getById(itemId)
.validateUpdateListItem([
{ FieldName: 'Tags', FieldValue: '' }
]);
βοΈ Adding a New Item with a Managed Metadata Field
When creating a new item with a managed metadata field, use validateUpdateListItem after the item is created β not as part of the initial add() call:
export async function createProjectWithDepartment(
sp: SPFI,
title: string,
termLabel: string,
termGuid: string
): Promise<void> {
// Step 1: Create the item (without the taxonomy field)
const result = await sp.web.lists
.getByTitle('Projects')
.items
.add({ Title: title });
// Step 2: Set the managed metadata field on the new item
const newItemId = result.data.Id;
await sp.web.lists
.getByTitle('Projects')
.items
.getById(newItemId)
.validateUpdateListItem([
{
FieldName: 'Department',
FieldValue: `-1;#${termLabel}|${termGuid}`
}
]);
}
π Getting the Internal Field Name
The internal name of a managed metadata field is often different from its display name. To find it:
// List all fields and find your taxonomy field
const fields = await sp.web.lists
.getByTitle('Projects')
.fields
.filter(`TypeAsString eq 'TaxonomyFieldType' or TypeAsString eq 'TaxonomyFieldTypeMulti'`)
.select('InternalName', 'Title', 'TypeAsString')();
fields.forEach(f => console.log(f.InternalName, f.Title, f.TypeAsString));
Use the InternalName value in all validateUpdateListItem calls and $select parameters. Display names with spaces cause unpredictable results.
β Summary
- Read managed metadata fields with
.select('FieldName').expand('FieldName')β withoutexpand, you get only the WssId integer. - Multi-value taxonomy fields return an array β normalise with
Array.isArray(item.Field) ? item.Field : item.Field?.results ?? []. - Always use
validateUpdateListItemfor writes β it handles WssId resolution and is the only safe method for taxonomy fields. - Wire format for a single value:
-1;#<label>|<termGuid>. For multiple values: join with;#. - When creating a new item with a taxonomy field, create the item first, then set the taxonomy field in a separate
validateUpdateListItemcall. - Use the field's
InternalName(not display name) in all API calls.
Happy coding!


