Update a record
We strongly recommend reading the Introduction to Records guide first. The payload for updating a record follows the same structure as creating one, so that guide is also an essential prerequisite.
The fundamental rules for structuring field values (i.e., strings, numbers, objects, references) are the same for both creating and updating records. For a complete reference on how to format the value for every field type, please see the Field Types Overview in the main records guide.
When updating an existing record, you only need to provide the fields you want to change. Any fields you omit from your payload will remain untouched.
There's a crucial difference between omitting a field and explicitly setting it to null or an empty value:
- Omitted fields keep their existing values unchanged
- Fields set to
nullor empty values (like[]for arrays) are cleared/deleted
The following sections highlight the rules and strategies that are specific to the update process.
TypeScript typing
Writing an update payload without typed schemas means writing blind: every field is unknown, typos compile fine, and mistakes only surface as 422s from the API. The single biggest lever you have is passing a generated Schema.X marker as the generic on items.update. TypeScript then enforces the model's field names, types, and allowed block shapes at compile time:
// ❌ Untyped: every field is `unknown`, typos compile.await client.items.update("record-id", { /* … */ });
// ✅ Typed: field names, types, and block shapes enforced.await client.items.update<Schema.Article>("record-id", { /* … */ });When you're rebuilding a field value piece-by-piece — typically an array of blocks or links — annotate the accumulator with FieldValueInRequest<T, 'field_key'>. The first argument accepts any item-shaped value the CMA produces (top-level record or nested block, narrowed or not) or a Schema.X marker directly when no value is in scope yet:
const article = await client.items.find<Schema.Article>("record-id", { nested: true });
if (article.content) { const content: NonNullable<FieldValueInRequest<typeof article, "content">> = /* ...transform article.content... */; await client.items.update<Schema.Article>("record-id", { content });}
// Same expression on a narrowed nested block:for (const block of article.sections) { if (isBlockOfType(Schema.HeroBlock.ID, block)) { const ctas: NonNullable<FieldValueInRequest<typeof block, "ctas">> = []; // …rebuild the nested field… }}
// Or, if you don't have a value yet, pass the marker:type ArticleContent = NonNullable<FieldValueInRequest<Schema.Article, "content">>;Even fields with a required validator are typed as Nullable, because the API may still accept/return null in some scenarios.
The example therefore checks if (article.content) and uses NonNullable<…> to narrow the type. Without this, accessing nested properties like content.document.children would require optional chaining (?.).
Updating Block Fields
The general workflow is the same for every block field type:
- Generate types with the TypeScript schema generator.
- Fetch records with
nested: trueso blocks come back as full objects you can edit. Without it you only get IDs — fine for keep/reorder/delete, not for editing. - Build the payload following the per-field-type rules below. Lean on the provided helpers as much as possible instead of assembling structures by hand.
- Send a single
client.items.update<Article>call, omitting fields you don't change.
Modular Content
Payload is an array of blocks. Each entry expresses one operation:
| Operation | Entry |
|---|---|
| Keep | block ID string |
| Edit | buildBlockRecord<T>({ id, ...changedAttrs }) |
| Create | buildBlockRecord<T>({ ...allAttrs }) |
| Clone | duplicateBlockRecord<T>(block, schemaRepository) |
| Delete | omit from the array |
| Reorder | rearrange the array |
Single Block
A single-block field holds at most one block (or null), so the payload is the value itself — not an array. The Modular Content rules apply, minus anything position-related.
Structured Text
Structured Text stores rich content as a DAST tree. Embedded records show up as two extra node kinds: block and inlineBlock. Both wrap a full DatoCMS block, so the same buildBlockRecord/duplicateBlockRecord primitives from Modular Content and Single Block still apply, but they're composed inside a tree-mutation pipeline rather than dropped into an array slot.
The pipeline has two passes, each exposing the document at a different level of granularity. Pick the highest-level pass that can still see what you want to change. Going lower buys you more power but costs you a lot of tree gymnastics.
Pass 1: Rewrite the prose (dastdown)
datocms-structured-text-dastdown translates the DAST tree to and from a markdown-like text format:
A normal paragraph with ==highlighted==, ++underlined++ and ~~struck~~ text.
Links can carry trailers: [our docs](https:datocms.com){target="_blank"}.References to other records read like [this article](dato:item/abc123),or inline as <inlineItem id="abc123"/>.
> A pull quote from the interview.> {attribution="Jane Doe"}
<block id="def456"/>
Inline blocks <inlineBlock id="ghi789"/> sit mid-sentence.This allows you to make changes with ordinary string operations and let parse() rebuild the tree:
import { parse, serialize } from "datocms-structured-text-dastdown";
const text = serialize(currentRecord.content);
const edited = text .replace(/Jane Doe/g, "Jane Smith") .replace(/==([^=]+)==/g, "**$1**");
const content = parse(edited, currentRecord.content);Blocks appear in dastdown only as ID placeholders: you can move or delete them, but you can't touch what's inside.
If you can describe the change as something you'd do in a text editor — find-and-replace, rewriting a paragraph, reshaping a list — dastdown is the right tool. It also wins on the opposite end of the spectrum from Pass 2: one-off spot edits, and agentic flows where an LLM reads the serialized text and rewrites it directly.
Pass 2: Mutate the tree (mapNodes + block helpers)
Reach for Pass 2 when dastdown can't see what you want to change — anything that depends on node kind rather than text, or on a block's internal fields rather than its position. Both flavors of edit live inside the same mapNodes walk:
- Transforming prose nodes. Rewrite every link's URL, lowercase every heading, bump every
level: 2heading tolevel: 3, drop every empty paragraph, wrap every occurrence of "click here" in a link. dastdown is the right tool while edits stay mostly text-shaped; once you find yourself writing regex to fish node kinds back out of the serialized form, the AST is the cleaner layer. - Editing or building embedded blocks. Edit a block's attributes, swap it for another, or drop a brand-new block into the tree. Embedded blocks are opaque to Pass 1 (dastdown serializes them to
<block id="…"/>placeholders that hide their fields), so anything touching block contents lands here. UsebuildBlockRecord<T>to shape the payload — pass anidto edit an existing block, omit it to create a new one — andduplicateBlockRecord<T>to deep-clone one.
mapNodes from datocms-structured-text-utils walks the tree bottom-up (a node's descendants have already been transformed by the time the callback sees it, and what you return for that node is final). Return one node (1:1, the default), an array splatted into siblings (1:N — split, wrap, insert), or null/undefined to drop (1:0 — illegal at the root).
When a node doesn't need to change, just return node. The CMA accepts the nested-response shape it came in as.
Adding root-level nodes (a brand-new paragraph, a fresh top-level block) sits just outside the callback: mapNodes can't splat at the root, so push directly into content.document.children after the walk — using buildBlockRecord / duplicateBlockRecord to shape any new block entry.
Pass 1's parse() uses the original document as the lookup table for <block id="…"/> placeholders. A block created by Pass 2 first would either be missing from that lookup (and parse throws) or get silently overwritten when Pass 1 rehydrates. If you need both, always run Pass 1 before Pass 2.
Block & node helpers
Curated index of the helpers used above.
Block helpers
Used by all three block field types. Full reference: block-processing-utilities.
| Need | Helper |
|---|---|
| Build a block (create / edit) | buildBlockRecord<T>(...) |
| Clone a block (deep copy, IDs stripped) | duplicateBlockRecord<T>(block, schemaRepository) |
| Inspect a record or block (debug) | inspectItem(record) |
| Inline narrowing on a block union | block.__itemTypeId === "..." |
Type-guard predicate for Array#filter / Array#find | isBlockOfType(id) |
| Recurse into every block (any depth, any field) | visitBlocks* / mapBlocks* / filterBlocks* / findAllBlocks* / reduceBlocks* / someBlocks* / everyBlocks* InNonLocalizedFieldValue |
Structured-text node helpers
Used only by structured_text. Each helper has an *Async mirror (mapNodes → mapNodesAsync) for async callbacks. Full reference: tree-manipulation-utilities.
| Need | Helper |
|---|---|
| Narrow a node to a kind | isParagraph, isHeading, isSpan, isLink, isItemLink, isInlineItem, isBlock, isInlineBlock, isList, ... |
| Narrow a block / inline-block node to a specific model in one step | isBlockWithItemOfType(id), isInlineBlockWithItemOfType(id) |
| Walk every node (side effect) | forEachNode |
| Transform every node (1:1, splat into siblings, or drop) | mapNodes |
| Find first / collect every match | findFirstNode, collectNodes |
| Fold to a single value | reduceNodes |
| Short-circuit checks | someNode, everyNode |
| ASCII-tree debug | inspect |
Updating Localized Fields
➡️ Before proceeding, ensure you have read the general guide on Localization
When you send an update request, the API follows these strict rules.
Rule 1: To change a locale value, send the whole set
When you update a translated field, you must provide the entire object for that field, including all the languages you want to keep unchanged. You can't just send the one language you're changing.
- Correct: To update the Italian title, you send both English and Italian:
{"title": {"en": "Hello World","it": "Ciao a tutti! (Updated)"}}
- Incorrect: If you only send the Italian value, the API will assume you want to delete the English one!
Rule 2: To add/remove a language, send all translated fields
This is the only time you can't just send the one field you're changing. To add or remove a language from an entire record, you must include all translated fields in your request. This is to enforce the Locale Sync Rule and ensure all fields remain consistent.
- Example: To add French to a blog post that already has a translated
titleandcontent, your request must include both fields with the newfrlocale.
Rule 3: Limited permissions? Only send what you can manage
If your API key only has permission for certain languages (e.g., only English), you must only include those languages in your update. The system is smart and will automatically protect and preserve the content for the languages you can't access (like Italian or French).
Update scenarios at a glance
This table shows what happens in different situations. The key takeaway is that your update payload defines the new final state for the languages you are allowed to manage.
| Your Role manages | Record currently Has | Your payload sends | Result |
|---|---|---|---|
| English | English | English | ✅ English is updated. |
| English, Italian | English | English, Italian | ✅ English is updated. ➕ Italian is added. |
| English, Italian | English, Italian | English | ✅ English is updated. ➖ Italian is removed. |
| English, Italian | English, Italian | English, Italian | ✅ English is updated. ✅ Italian is updated. |
| Eng, Ita, Fre | English, Italian | English, French | ✅ English is updated. ➖ Italian is removed. ➕ French is added. |
| English | English, Italian | English | ✅ English is updated. 🛡️ Italian is preserved. |
| English, Italian | English, French | English, Italian | ✅ English is updated. 🛡️ French is preserved. ➕ Italian is added. |
| English, Italian | English, French | Italian | ➖ English is removed. 🛡️ French is preserved. ➕ Italian is added. |
Block fields
The rules about localization work in combination with the rules for updating blocks: you use full block objects to create/update and block IDs to leave unchanged, but you do so within the object for a specific locale.
Example: Updating a block in one locale
This payload updates the title of an existing block in the en locale, while leaving the second English block and all Italian blocks untouched. The it locale needs to be included in the payload, or the Italian locale will be deleted!
{ "content_blocks": { "en": [ { "id": "dhVR2HqgRVCTGFi0bWqLqA", "type": "item", "attributes": { "title": "Updated English Title" } }, "kL9mN3pQrStUvWxYzAbCdE" ], "it": [ "dhVR2HqgRVCTGFi_0bWqLqA", "kL9mN3pQrStUvWxYzAbCdE" ] }}Example: Adding a new block to one locale
This payload adds a new block to the it locale only. The en locale needs to be included in the payload, or the Italian locale will be deleted!
{ "content_blocks": { "en": [ "dhVR2HqgRVCTGFi_0bWqLqA", "kL9mN3pQrStUvWxYzAbCdE" ], "it": [ "fG8hI1jKlMnOpQrStUvWxY", { "type": "item", "attributes": { "title": "Nuovo Blocco" }, "relationships": { "item_type": { "data": { "id": "BxZ9Y2aKQVeTnM4hP8wLpD", "type": "item_type" } } } }, "dhVR2HqgRVCTGFi0bWqLqA" ] }}Example: Adding a new locale
To add a new locale to an existing record, you must provide values for all localized fields for that new locale, and include existing locales that you want to preserve.
{ "title": { "en": "English Title", "fr": "Titre Français", }, "content_blocks": { "en": [ "dhVR2HqgRVCTGFi_0bWqLqA", "kL9mN3pQrStUvWxYzAbCdE" ], "fr": [ { "type": "item", "attributes": { "title": "Nouveau Bloc Français" }, "relationships": { "item_type": { "data": { "id": "BxZ9Y2aKQVeTnM4hP8wLpD", "type": "item_type" } } } } ] }}Helpers shipped with our JS CMA clients let you skip the "does this field have a locale object or just a value" branching when reading or transforming field values — jump to Unified locale helpers.
Unified locale helpers
These utilities let you treat localized and non-localized field values with the same code path — no branching on "does this field have a locale object or just a value". Each helper has an *Async mirror (mapNormalizedFieldValues → mapNormalizedFieldValuesAsync) for async callbacks. See the full helper reference for signatures and examples.
mapNormalizedFieldValues(): apply a transformation to each locale (or to the single value on non-localized fields)filterNormalizedFieldValues(): keep only locales / values matching a predicatevisitNormalizedFieldValues(): run a side effect for each locale / valuesomeNormalizedFieldValues():trueif at least one locale / value matcheseveryNormalizedFieldValue():trueif all locales / values matchtoNormalizedFieldValueEntries()/fromNormalizedFieldValueEntries(): convert to / from a unified[locale, value][]shape for iteration
Bulk block operations
Sometimes, you need to perform mass operations on any block of a specific kind, regardless of where they're embedded in your content structure — whether in Modular Content fields, Single Block fields, or deeply nested within Structured Text documents. In these cases, manually traversing each record and field would be extremely time-consuming and error-prone.
DatoCMS provides powerful utilities that can systematically discover, traverse, and manipulate blocks across your entire content hierarchy. These utilities handle the complexity of localized content, nested structures, and different field types automatically, making what would otherwise be a complex operation straightforward and reliable.
Optimistic Locking
To prevent clients from accidentally overwriting each other's changes, the update endpoint supports optimistic locking. You can include the record's current version number in the meta object of your payload.
If the version on the server is newer than the one you provide, the API will reject the update with a 422 STALE_ITEM_VERSION error, indicating that the record has been modified since you last fetched it.
Body parameters
Must be exactly "item".
Date of creation
Date of first publication
The ID of the current record version (for optimistic locking, see the example)
"4234"
The new stage to move the record to
The entity (account/collaborator/access token/sso user) who created the record
Returns
Returns a resource object of type item.
Examples
PUT https://site-api.datocms.com/items/:item_id HTTP/1.1Authorization: Bearer YOUR-API-TOKENAccept: application/jsonX-Api-Version: 3Content-Type: application/vnd.api+json
{ "data": { "type": "item", "id": "hWl-mnkWRYmMCSTq4z_piQ" }}curl -g 'https://site-api.datocms.com/items/:item_id' \ -X PUT \ -H "Authorization: Bearer YOUR-API-TOKEN" \ -H "Accept: application/json" \ -H "X-Api-Version: 3" \ -H "Content-Type: application/vnd.api+json" \ --data-binary '{"data":{"type":"item","id":"hWl-mnkWRYmMCSTq4z_piQ"}}'await fetch("https://site-api.datocms.com/items/:item_id", { method: "PUT", headers: { Authorization: "Bearer YOUR-API-TOKEN", Accept: "application/json", "X-Api-Version": "3", "Content-Type": "application/vnd.api+json", }, body: JSON.stringify({ data: { type: "item", id: "hWl-mnkWRYmMCSTq4z_piQ" }, }),});HTTP/1.1 200 OKContent-Type: application/jsonCache-Control: cache-control: max-age=0, private, must-revalidateX-RateLimit-Limit: 30X-RateLimit-Remaining: 28
{ "data": { "type": "item", "id": "hWl-mnkWRYmMCSTq4z_piQ", "relationships": { "item_type": { "data": { "type": "item_type", "id": "DxMaW10UQiCmZcuuA-IkkA" } } }, "attributes": { "title": "My first blog post!", "content": "Lorem ipsum dolor sit amet...", "category": "24", "image": { "alt": "Alt text", "title": "Image title", "custom_data": {}, "focal_point": null, "upload_id": "20042921" } }, "meta": { "created_at": "2020-04-21T07:57:11.124Z", "updated_at": "2020-04-21T07:57:11.124Z", "published_at": "2020-04-21T07:57:11.124Z", "first_published_at": "2020-04-21T07:57:11.124Z", "publication_scheduled_at": "2020-04-21T07:57:11.124Z", "unpublishing_scheduled_at": "2020-04-21T07:57:11.124Z", "status": "published", "is_current_version_valid": true, "is_published_version_valid": true, "current_version": "4234", "stage": null, "has_children": true } }}