logo
Published on

Getting and Setting Managed Metadata Fields via PnPjs

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') β€” without expand, 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 validateUpdateListItem for 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 validateUpdateListItem call.
  • Use the field's InternalName (not display name) in all API calls.

Happy coding!

Ad image