Skip to content

Changesets

A changeset is a content change that ships with code and runs exactly once per environment: hook_update_N, for content.

drush content-deployment:status          # what has run here, and what is waiting
drush content-deployment:run --dry-run   # what the pending ones would do
drush content-deployment:run             # run them
drush content-deployment:run --publish   # run them and publish what they write
drush content-deployment:run --id=site:0003_opening_hours   # one, out of order

Why not a committed spec

content-spec:apply is declarative: it empties a field the spec omits and drops a paragraph the spec no longer names. A spec committed in March and applied again in June reverts every editorial change made in between. A changeset runs once, is recorded, and is never considered again. When the page needs to change again, that ships as a later changeset, which takes the content from wherever it has got to.

Where changesets live

$settings['content_deployment_directory'] = '../content';

An absolute path, or one relative to the Drupal root, outside the docroot. Changesets are files in its changesets directory, named so they sort:

content/
  changesets/
    0001_opening_hours.json
    0002_retire_old_contacts.json
Found in Id
<content_deployment_directory>/changesets/*.json site:<filename without extension>
<module>/content/changesets/*.json, for each enabled module <module>:<filename without extension>

A top-level "id" in a file replaces the derived id. A changeset with an explicit id reads as applied wherever its file is, so it can move between directories. Two files with the same id are both reported as broken and neither runs.

Changesets run in order of file name first and source (site or the module name) second, so numbering across sources gives the order written down.

Modules are inside the docroot, so the status report warns, naming them, when an enabled module ships a content/changesets directory, and when content_deployment_directory resolves inside the docroot. Without the setting, only module changesets are found.

The file

A file with an entities or unpublish key is a changeset:

{
  "id": "opening-hours-2026",
  "description": "Opening hours for 2026.",
  "publish": true,
  "entities": [
    { "entity_type": "node", "bundle": "landing", "uuid": "0a8ba38b-c932-40ca-bd7f-f28a87686ac8", "title": "Opening hours" }
  ],
  "unpublish": [
    { "entity_type": "node", "uuid": "5b9c7f4e-2a1d-4c3b-8e6f-9d0a1b2c3d4e", "title": "Opening hours 2025" }
  ]
}
Key Meaning
id Optional. Replaces the derived id.
description Shown by content-deployment:status.
publish true publishes what the changeset writes. Default false.
entities Content specs, written in order. An entry with a _base map is a patch: see Patches.
unpublish Entities to take offline after entities are written, each {entity_type, uuid}. title is for the reader and is not checked. An entry may carry a _base: see Unpublishing with a base.
files The files the changeset carries, by SHA-256: see Files.

Anything else is read as a single spec, so drush content-spec:read 2 > changesets/0002_front_page.json is a changeset as it stands. Its description is the spec's title, and a bare spec cannot ask to be published.

Upsert

Each entity is looked up by uuid. One that resolves is updated, as content-spec:apply does it; anything else is created, with the uuid from the spec. content-spec:create and content-spec:apply stay strict, refusing an existing or an unknown uuid; a changeset has said which it means by shipping.

Unpublishing

Each unpublish entry is taken offline as content-spec:unpublish does it: the workflow's archived state, an unpublished revision, or a blocked account. Nothing is deleted. A uuid that does not resolve is reported as not here and skipped, since a site built from the repository never had the content; one already offline is reported as already offline and left alone.

Publishing

Everything a changeset writes is a draft unless the changeset says "publish": true or the command is given --publish, which publishes everything that run writes.

What is recorded

One record per changeset id in the key-value collection content_deployment.changesets, in the environment's database:

Key Value
applied When it ran, a Unix timestamp.
hash The SHA-256 of the file as it ran.
published Whether it published what it wrote.
entities <entity_type>:<uuid> of each entity written.
unpublished <entity_type>:<uuid> of each entity taken offline.
conflicts Patches only, when there were any: the conflicting paths of each entity, and the reason for each unpublish skipped, by <entity_type>:<uuid>.
flags Patches only, when there were any: what each entity's outcome needs a reviewer to know, by <entity_type>:<uuid>.
note On the site a changeset was exported from: why it is recorded without having run.

A database copied from another environment arrives knowing what has run there. Changesets are recorded individually, not as a high-water mark, so one merged from a branch after a higher number has landed still runs.

content-deployment:status lists every changeset found:

Status Meaning
pending Not run here.
applied Run here, and the file is as it was.
EDITED Run here, and the file's SHA-256 no longer matches the record. It will not run again; ship the change as a new changeset. The command also logs a warning naming them.
BROKEN The file cannot be run: it does not parse, has an invalid id, or shares its id with another file.

A run stops at the first failure. Nothing is recorded for a changeset that failed, so fixing it and running again starts it from the top; an entity it had already written is an update the second time.

--id=<id> runs one changeset out of order, and refuses one that has already run.

Making a spec worth committing

drush content-spec:read 2 --portable
drush content-spec:read 2 --structure-only

--portable names content references instead of identifying them by uuid, wherever the name is unambiguous within the bundles the field accepts. A uuid means one entity on one database; a name travels to a site built from the same repository. Where a name is ambiguous, the uuid is kept.

--structure-only omits the prose and keeps the shape: which components, in what order, with which settings. Such a spec carries _structure_only: true and is refused by every write, since apply would empty what it omits. It is for reviewing and diffing.

Patches

A changeset entry with a _base map is a patch: it holds only the paths that changed, each fingerprinted with its value before the change. The runner writes a path only where this site still has that value, so edits made here since survive. An entry without _base is a full-state spec, with upsert as above; a changeset without patches or files runs exactly as described there.

{
  "entity_type": "node",
  "bundle": "landing",
  "uuid": "0a8ba38b-c932-40ca-bd7f-f28a87686ac8",
  "_base": {
    "en:title": "6f1ed002ab5595859014ebf0951522d9a9a2cbfcac3f8fbdcc1bb5d0b4bf61b9",
    "en:paragraph/3f0c5b0e-7d8a-4b6e-9c1d-2e3f4a5b6c7d/field_heading": "c1c39b8e6a8a4b7f55b6c3f1a5ad0f8f2c4f1d2e3b4a5c6d7e8f9a0b1c2d3e4f",
    "en:paragraph/9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a/field_cards": "0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8"
  },
  "_base_revision": 41,
  "title": "Opening times",
  "paragraphs": {
    "3f0c5b0e-7d8a-4b6e-9c1d-2e3f4a5b6c7d": { "field_heading": "By boat" },
    "9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a": {
      "field_cards": [
        "3f0c5b0e-7d8a-4b6e-9c1d-2e3f4a5b6c7d",
        { "bundle": "card", "uuid": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "field_heading": "By bike" }
      ]
    }
  }
}

Paths

Path Value
<langcode>:<field> A field of the entity. title and path are the spec's own keys: the label, and the custom alias (null when the alias is generated or absent).
<langcode>:status The publishing state: the workflow state's id under content moderation, true or false otherwise. Read from the newest revision.
<langcode>:paragraph/<uuid>/<field> A field of a paragraph anywhere in the entity's tree.

A paragraph field's own path holds its paragraphs' uuids in order, not their contents, so reordering a list and editing a paragraph in it are separate changes. Untranslatable fields use the entity's default langcode.

The entity's default language has a path for every value. Each other language has paths for what it translates: its label and alias, its translatable fields, and its translation of each paragraph's, such as el:paragraph/<uuid>/field_heading.

Values are spec values with references as uuids. A file is referenced by the SHA-256 of its bytes, {"sha256": …} beside the item's other properties, so the same image compares equal on every site. An empty field is null.

Keys of a patch

Key Meaning
_base Each path the patch writes, mapped to the SHA-256 of its value before the change. An empty map says the entity is new.
_base_revision Optional. The id of the revision the base was read from.
_unit Optional. Entries with the same _unit are decided together: see Units.
a field name, title, path The new value of that host path. null empties it.
status Publishing, as a change of its own: a published state, the workflow state's id or true. A patch does not take content offline; an unpublish entry does.
translations Another language's changed host values, keyed by langcode, as in a content spec.
paragraphs Changed paragraph fields, keyed by paragraph uuid, each holding only its changed fields. A reserved key in every spec.

A paragraph field appears only when its order or membership changed, as a list: a uuid keeps or moves in an existing paragraph, an object is a new paragraph written out whole, with its uuid and its translations. A paragraph's changed translations sit in its paragraphs entry under translations. children is not accepted in a patch; name the field.

Fingerprints

A fingerprint is the SHA-256, in lowercase hexadecimal, of the value's canonical JSON as RFC 8785 defines it: object members sorted by the UTF-16 code units of their names, no whitespace, strings escaping only what JSON requires. Two departures:

  • A float is written as a JSON string holding PHP's shortest round-trip form, such as "1.5", "1.0" or "1.0e+25". Field values can hold floats, and a fingerprint must not depend on number formatting.
  • An empty object and an empty list are both [], as PHP has one empty array.

tests/fixtures/canonical-json.json holds values with their canonical text and digests, made by a separate implementation.

Per path

Each path is compared with this site's newest revision of the entity, which is a pending draft when there is one; in several languages, each language's paths with that language's newest revision. A language this site lacks holds nothing, so each of its values compares as empty:

This site has The path
the base value is applied
the new value is skipped
anything else conflicts

A path the patch does not name is never written.

When _base_revision names this site's default revision of the same uuid, and no draft is pending, nothing stored in a revision can have changed since the base, so paths are applied without comparing. path is compared regardless: an alias is its own entity and changes without a revision. A revision id that belongs to another entity is ignored, since ids are per site.

Outcomes

Entity Outcome
No conflict, no draft pending Its applied paths are written as a new revision, published when the changeset publishes.
Unpublished here, and the patch has no status Its applied paths are written, and it stays unpublished, flagged: the publishing state is a path the changeset did not touch.
A conflict A pending draft built on this site's current revision, with every staged path applied, flagged for review.
A draft already pending The draft is built on, never discarded, and the result stays a draft, flagged.
_base is empty, and the entity is not here Created, from every path of the entry, and flagged.
_base is not empty, and the entity is not here Skipped, and flagged.
Every path skipped Nothing is written.

A patch writes one revision per language it changes, the default language's first, as content specs do; only the paths it names are written in each. A Greek edit made here is therefore no conflict for a staged English change, and survives it.

A draft cannot hold an alias: core saves an alias the moment its entity is saved, whatever the revision. A path in a patch that ends as a pending draft waits in the draft's review record instead, and is set by the save that publishes the draft, whoever makes it: the Publish action, content-spec:publish, or core's moderation form. A redirect keeps the old alias's links working. content-spec:read reports the waiting alias as the draft's path.

Conflicts never fail a run. The changeset is recorded with them, and the command exits 0.

Review

Each draft a patch leaves is recorded for review as content-spec:pending lists, with:

Key Value
changeset The changeset's id.
conflicts Each conflicting path, with the field's label, the value here before the run, and the value staged.
flags As in the run's output.
base_revision, base_revision_path The base revision and where to see it in this site's history, when this site has that revision.
held_alias {value}: the alias the draft waits for, null handing it back to Pathauto. Kept when a later draft is built on this one.

drush content-spec:pending --conflicts lists only those drafts. The notice on the page names the conflicting fields and links to the base revision. The event content_deployment.changeset_conflict (Drupal\content_deployment\Event\ChangesetConflictEvent) fires once per entity with a conflict, after its draft is saved, carrying the draft, the changeset id and the conflicts.

Units

A paragraph moving from one entity to another changes both: one list loses it, the other gains it. Entries with the same _unit are decided together: if any of them is held as a draft, all of them are, so the paragraph never leaves a published page for a draft of another.

Unpublishing with a base

An unpublish entry with a _base maps the state path, <langcode>:status, to the fingerprint of the entity's state before the change: the workflow state under content moderation, the published flag otherwise.

This site has The entry
the entity offline is skipped, as already offline
the base state, and no draft pending runs
anything else is skipped as skipped: conflict, and flagged: someone here is working on it

Files

A changeset carries files in its directory, beside changesets/, addressed by SHA-256:

content/
  changesets/0003_spring.json
  files/ab/ab12…ef.jpg
"files": {
  "ab12…ef": { "path": "files/ab/ab12…ef.jpg", "size": 48213, "uri": "public://2026-10/harbour.jpg" }
}
Key Meaning
path Relative to the directory holding changesets/.
size In bytes.
uri Optional. Where the file lived where it came from; a stored file goes there, renamed if the name is taken, when this site has that scheme, and under public://content-deployment/ otherwise.

Before anything is written, every file is checked: present, of that size, with those bytes. A Git LFS pointer, from a checkout made without git lfs pull, is named as such. Any problem fails the run, with nothing recorded.

Bytes this site already has are found through File Hash, and that file is used. The rest are stored as new permanent files, owned by the module's account. Every {"sha256": …} reference is then written as that file. A changeset carrying files needs File Hash with SHA-256 enabled; one without files does not.

Exporting from a staging workspace

content_deployment_export, on dev only, turns a staging workspace into the next changeset.

drush content-deployment:export stage --dry-run
drush content-deployment:export stage
drush content-deployment:export stage --label=spring_campaign

The same export is the Export changeset operation in the workspace list, for accounts with export content deployment changesets. Its confirm form shows what --dry-run prints: each entity with its action and paths, each file with its size, what is left out and why, and what stops the export. Where the web server cannot write the directory, the form offers the changeset and its files as a .tar.gz laid out as the directory is.

The export:

  • reads the workspace's tracked entities from workspaces.tracker, folding a paragraph into the entity at the top of its tree and an alias into the entity it names;
  • reads each entity inside the workspace for the staged values and outside it for the base, and writes every changed path into a patch, with _base and _base_revision; an entity the workspace created is written out whole, with an empty _base;
  • joins entities that a paragraph moved between into a _unit;
  • writes publishing as a change: an entity published in the workspace and not in Live gets a status in its patch, with Live's state as its base;
  • writes each language's changed paths: another language's under translations, a paragraph's translations in its paragraphs entry, and new paragraphs with their translations;
  • writes an entity unpublished in the workspace, and published in Live, as an unpublish entry with a base; one unpublished in the workspace otherwise is left out, since an export publishes what it carries;
  • copies the bytes of every file a changed path references to files/<first two hex>/<sha256>.<ext>, once per SHA-256, and refuses a file over the size limit;
  • writes changesets/NNNN_<label>.json, numbered after the highest number there, with an explicit id <workspace>:<NNNN>:<random>, "publish": true, and new entities first;
  • records the changeset as run on dev, where its content already is: running it into Live while the workspace is open would create the workspace's own entities a second time.

The workspace stays open. Every value exported is remembered per workspace in the key-value collection content_deployment_export.exported, and the next export holds only paths changed since, with the remembered fingerprints as _base.

Accounts and redirects cannot be saved inside a workspace, so they join a changeset as hand-written full-state entries in entities.

Setting Default Meaning
$settings['content_deployment_max_file_size'] 10485760 (10 MiB) The largest file an export carries, in bytes.

Running on deploy

$settings['content_deployment_run_on_deploy'] = TRUE;

With it, drush deploy runs the pending changesets after deploy:hook, as content-deployment:run would. Without it, drush deploy prints how many are pending, and the status report warns while any are.