Drupal: The Migrate API
|
This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production. This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages. |
Every site eventually has to take in data that did not start life as Drupal content: a spreadsheet exported from a registrar’s database, the tables of a retired Drupal 7 site, a JSON feed, a pile of Markdown files. The Migrate API is core’s answer. It is an extract-transform-load framework: a source plugin reads rows from somewhere, a process pipeline transforms every value, and a destination plugin writes the result to Drupal, normally as an entity. Around those three plugin types it keeps bookkeeping tables that remember which source row became which destination record, so that a migration can be run again, updated, stopped, resumed and rolled back. This page explains the pieces, shows how to write migrations as YAML, how to write your own source and process plugins with PHP attributes, what Migrate Plus and Migrate Tools add, and how to import the Meridian Museum’s collection from a CSV file safely and repeatedly.
It was written against Drupal 11.4.x (11.4.0 was released on 1 July 2026), Drush 13 or 14 and PHP 8.3 or later, and the facts were checked against the official documentation, change records, the API reference and the core source on 2026-10-30. Drupal 12.0.0 is scheduled for the week of 2026-12-07 and the end of life of Drupal 10 for 2026-12-09, so both are described as upcoming events. New sites start on Drupal 11.4.
The running example is the Meridian Museum, a fictitious regional science museum. Its module meridian_collection (see
Module anatomy and info.yml) defines the collection_object content entity
(see Custom entity types) with the fields accession_number, title,
description, period, material, dimensions and on_display. The registrar keeps the master list in a spreadsheet and
exports it to CSV once a quarter; this page builds the migration that turns that export into collection_object entities,
adds the Spanish translations, and makes it safe to run again whenever a new export arrives. Moving a whole Drupal 7 site is a
different task, covered in Migrating from Drupal 7; the plugins explained here
are the same ones that path uses.
Who works with the Migrate API
-
A developer writes migration definitions, custom source and process plugins, tests, and the glue that runs them.
-
A site builder or content lead hands over the source data, decides which fields map where and checks the result in the admin UI.
-
An administrator runs migrations with Drush on staging and production, looks at the messages they leave and decides when a rollback is the right answer.
The three plugin types and the pipeline
A migration is a plugin of type migration (a Drupal\migrate\Plugin\Migration object) assembled from a definition. The
definition names one source plugin, one process pipeline per destination property and one destination plugin. The three
plugin types live in the Plugin\migrate\source, Plugin\migrate\process and Plugin\migrate\destination sub-namespaces of
a module.
For each row the executor does the following:
-
The source plugin yields an associative array of values and the declared source identifiers. The executor wraps it in a
Drupal\migrate\Row. -
hook_migrate_prepare_row()(and the migration-specific variant) runs, and the source’s ownprepareRow()can add or change source properties or skip the row. -
If the row is already in the map table with an unchanged hash and the run is not an update, the row is skipped.
-
The process pipeline fills the destination properties. Each destination property has a chain of process plugins; the output of one is the input of the next.
-
The destination plugin receives the processed row, creates or updates the entity, validates it when asked to, and returns the destination identifiers.
-
The executor stores the pair of identifiers (and a hash of the source row) in the map table and goes to the next row.
Two things follow from this design. First, the source and the destination know nothing about each other: swapping a CSV file
for a JSON feed means changing only the source section. Second, nothing in the loop talks to the form system: a migration
bypasses widgets, form validation and entity access checks, which is why the destination’s validate option, explained
below, matters.
What is in core and what is contributed
The migrate module is part of core and provides the framework, the base classes, the map tables, the generic process plugins
(get, default_value, callback, static_map, skip_on_empty, migration_lookup, explode, concat, format_date,
sub_process, null_coalesce, flatten and others), the embedded_data and empty sources and the entity destinations.
The migrate_drupal and migrate_drupal_ui modules add the Drupal 6 and 7 upgrade path. Core has no Drush commands and no
admin UI for running a generic migration. Those come from contributed projects:
| Project | What it adds | Version checked on 2026-10-30 |
|---|---|---|
Migrate Plus ( |
Migrations as configuration entities, migration groups with shared configuration, a |
6.0.10 (23 January 2026), listed as working with Drupal ^10.5 |
^11 |
Migrate Tools ( |
|
Drush commands |
6.1.4 (13 May 2026), listed as working with Drupal ^9.1 |
|
^10 |
^11 |
|
Migrate Source CSV ( |
A |
8.x-3.8 (23 February 2026), listed as working with Drupal >=9.1 |
Migrate Tools builds on Migrate Plus, so installing it pulls Migrate Plus in. Whether each of these projects declares support for Drupal 12 is not settled until 12.0.0 is released; check the project pages before upgrading a site that depends on them. The Migrate API module itself is not on the list of modules leaving core in Drupal 12; the modules that do go, without a contributed replacement, are Migrate Drupal and Migrate Drupal UI.
Installing the tooling
The Meridian module needs the framework and the Drush commands, so the project requires Migrate Tools and the CSV source. The custom source plugin built later in this page does not need the CSV project; both are shown so that the trade-off is visible.
ddev composer require 'drupal/migrate_plus:^6.0' 'drupal/migrate_tools:^6.1' 'drupal/migrate_source_csv:^3.8'
ddev drush pm:install migrate migrate_plus migrate_tools migrate_source_csv
ddev drush migrate:status
Source: Migrate Tools project page
Because Composer is the only supported way to add code to a Drupal 11 site, the packages are added with composer require
and committed with composer.lock. The migration modules are needed on every environment that runs a migration, but a
production site that only ever receives the results (the imported content, through a database copy) does not need them
enabled; many teams enable them for the duration of a migration and uninstall them afterwards.
Anatomy of a migration definition
A migration is a YAML file. These are its top-level keys:
| Key | Meaning |
|---|---|
|
Machine name, unique on the site. It is part of the map table name ( |
|
Human readable name shown by |
|
Optional group (Migrate Plus). A group can supply shared configuration, such as one source path, to its members. |
|
A list of free-form tags. |
|
Two lists, |
|
The source plugin id ( |
|
A map of destination property to either a source property name (shorthand for the |
|
The destination plugin id and its configuration. |
|
Configuration dependencies, for example |
Where the file lives: plugin or configuration entity
There are two ways to ship a definition, and the choice has consequences.
-
A migration plugin: a file in the module’s
migrations/directory, for examplemeridian_collection/migrations/meridian_collection_objects.yml. It is discovered like any other YAML plugin definition, is part of the code, is deployed with the code and cannot be edited in the UI. This is the core way and needs only themigratemodule. It is the right choice for a module that owns its import, like this one. -
A configuration entity: a file named
migrate_plus.migration.<id>.yml, normally inconfig/install(shipped with a module) or in the exported configuration directory (see Configuration management). It needs Migrate Plus. It can be imported from the admin UI atadmin/config/development/configuration/single/importby choosing the "Migration" type, exported and edited atadmin/config/development/configuration/single/export, and it supports migration groups. The price is that it is configuration: it travels throughconfig:exportandconfig:import, and an edit on production is lost or reverted by the next import.
The two forms are the same YAML; only the file name and location differ. Drupal 11 documentation for Migrate still shows both, and a project can mix them. The examples below use plugin files unless stated.
The Meridian CSV
The registrar’s export has one row per object, a header row and UTF-8 text. The file is placed in the private file system, not under the web root, because it can contain unreleased acquisitions (see File system and private files).
accession_number,title,description,period,material,dimensions,on_display
mm.1987.0042,Foucault pendulum bob,"Brass bob from the 1911 gallery pendulum, restored in 1987.",Industrial age,Brass,"38 cm diameter",yes
MM.1992.0108,Roman oil lamp,"Terracotta lamp with a griffin relief, found near the river crossing.",roman,Terracotta,"11 x 7 x 4 cm",no
MM.2003.0211,"Orrery, tabletop","Cased tabletop orrery by a regional instrument maker.",Modern,"Walnut, brass","32 x 32 x 21 cm",1
MM.2003.0211,Duplicate by mistake,"This row repeats an accession number.",Modern,Brass,"1 x 1 x 1 cm",0
,Bronze axe head,"Row without an accession number.",Neolithic,Bronze,"14 cm",no
The file is deliberately untidy, because real exports are: lower case accession numbers, free-text period labels that do not match the field’s allowed values, several spellings of a boolean, a duplicated identifier and a row without one. The migration below handles each of these, and the sections that follow explain why it behaves as it does.
Source plugins
A source plugin’s job is to turn something external into an iterator of associative arrays, to say which keys identify a row,
and to describe the available fields. A source plugin implements Drupal\migrate\Plugin\MigrateSourceInterface; almost all
extend Drupal\migrate\Plugin\migrate\source\SourcePluginBase, which implements the iteration, counting, hashing, high-water
handling and the prepareRow() hook invocation. A SQL source extends SqlBase and implements query(); other sources
implement initializeIterator().
| Source plugin | Provided by | Typical use |
|---|---|---|
|
core |
Rows written into the definition itself ( |
|
core |
A migration with no rows, used for stubs and for dependencies that only need to exist |
|
core |
Reading the tables of an old site through a second database connection |
|
Migrate Source CSV |
Delimited files |
|
Migrate Plus |
HTTP or file data in JSON, XML, SimpleXML or SOAP, with authentication |
Your own plugin |
your module |
Anything else: a proprietary export, a REST API with unusual pagination, a directory of Markdown files |
The generic source keys, available to every plugin that extends SourcePluginBase:
-
track_changes: truestores a hash of each source row so that a later run can tell which rows changed. -
high_water_property: { name: changed }remembers the highest value of a field, typically a timestamp, and on the next run reads only rows above it. It cannot be combined withtrack_changes; the constructor throws aMigrateExceptionif both are set. -
cache_counts: trueandskip_count: truecontrol the row count thatmigrate:statusshows, which can be expensive for a remote source. -
constantsholds fixed values that process pipelines reference asconstants/name.
The contributed CSV source
With Migrate Source CSV the whole source is configuration:
# meridian_collection/migrations/meridian_collection_objects_csv_contrib.yml
id: meridian_collection_objects_csv_contrib
label: 'Meridian collection objects (contributed CSV source)'
migration_tags:
- Meridian
source:
plugin: csv
path: 'private://meridian-import/objects.csv'
ids:
- accession_number
header_offset: 0
track_changes: true
process:
accession_number: accession_number
title: title
destination:
plugin: 'entity:collection_object'
Source: Migrating data from a CSV source
ids lists the columns that uniquely identify a row, header_offset: 0 says that the first row holds the column names (a
null offset means there is no header row and the fields key must list the column names), and path accepts any stream
wrapper URI. The project page says the plugin also supports custom delimiters, enclosures and escape characters. The contributed plugin is the
fastest route when the file is well formed.
A custom CSV source with a PHP attribute
A custom plugin is justified when the file needs behaviour a generic plugin does not give: a byte-order mark, header
validation with a helpful error, a trimmed identifier, or a file that must not be loaded into memory. The Meridian registrar’s
files start with a byte-order mark because they are saved by a spreadsheet, so the module reads them itself. Since Drupal
10.3 migrate plugins are discovered through PHP attributes; the attribute class is
Drupal\migrate\Attribute\MigrateSource, and it replaces the @MigrateSource annotation that older tutorials show.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Plugin\migrate\source;
use Drupal\migrate\Attribute\MigrateSource;
use Drupal\migrate\Exception\MigrateException;
use Drupal\migrate\Plugin\migrate\source\SourcePluginBase;
/**
* Reads the registrar's CSV export, one row per collection object.
*
* Configuration:
* - path: A stream URI or path of the CSV file, for example
* private://meridian-import/objects.csv.
* - delimiter: (optional) A one character delimiter, "," by default.
*/
#[MigrateSource(id: 'meridian_csv', source_module: 'meridian_collection')]
final class MeridianCsv extends SourcePluginBase {
/**
* The columns every export must contain.
*/
private const REQUIRED_COLUMNS = ['accession_number', 'title'];
/**
* {@inheritdoc}
*/
public function fields(): array {
return [
'accession_number' => $this->t('Accession number, the inventory id'),
'title' => $this->t('Object title'),
'description' => $this->t('Description, plain text'),
'period' => $this->t('Period, free text'),
'material' => $this->t('Material'),
'dimensions' => $this->t('Dimensions with unit'),
'on_display' => $this->t('On display: yes, no, 1 or 0'),
];
}
/**
* {@inheritdoc}
*/
public function getIds(): array {
return [
'accession_number' => [
'type' => 'string',
'max_length' => 32,
],
];
}
/**
* {@inheritdoc}
*/
public function __toString(): string {
return (string) ($this->configuration['path'] ?? 'meridian_csv');
}
/**
* {@inheritdoc}
*/
protected function initializeIterator(): \Iterator {
$path = $this->configuration['path'] ?? '';
$delimiter = $this->configuration['delimiter'] ?? ',';
if ($path === '' || !is_readable($path)) {
throw new MigrateException(sprintf('The CSV file "%s" does not exist or is not readable.', $path));
}
return $this->readRows($path, $delimiter);
}
/**
* Yields one associative array per data row, without loading the file.
*/
private function readRows(string $path, string $delimiter): \Generator {
$handle = fopen($path, 'rb');
if ($handle === FALSE) {
throw new MigrateException(sprintf('The CSV file "%s" could not be opened.', $path));
}
try {
// The escape argument is passed explicitly: PHP 8.4 deprecates relying
// on its default value.
$header = fgetcsv($handle, NULL, $delimiter, '"', '');
if ($header === FALSE) {
throw new MigrateException(sprintf('The CSV file "%s" is empty.', $path));
}
// Remove the byte-order mark that spreadsheets add to the first cell.
$header[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) $header[0]);
$header = array_map(static fn ($name) => trim((string) $name), $header);
$missing = array_diff(self::REQUIRED_COLUMNS, $header);
if ($missing) {
throw new MigrateException(sprintf('The CSV file "%s" lacks the column(s): %s.', $path, implode(', ', $missing)));
}
while (($values = fgetcsv($handle, NULL, $delimiter, '"', '')) !== FALSE) {
// A blank line is returned as an array holding a single NULL.
if ($values === [NULL]) {
continue;
}
// Pad short rows so that array_combine() never fails.
$values = array_pad($values, count($header), '');
yield array_combine($header, array_slice($values, 0, count($header)));
}
}
finally {
fclose($handle);
}
}
}
Notes on this class:
-
initializeIterator()is the only abstract method that reads data. The base class calls it for the import, and again, separately, to count rows, which is why it returns a fresh iterator each time instead of caching one. -
getIds()declares the identifier columns and their schema. They become columns of the map table, so amax_lengththat matches the entity field (32 here) keeps the table narrow. -
fields()is descriptive: it feedsmigrate:fields-sourceand documentation, not the import itself. Row values come from whatever keys the iterator yields. -
A plugin with no constructor needs no dependency injection. When a source needs a service (an HTTP client, a file system), implement
Drupal\Core\Plugin\ContainerFactoryPluginInterface, add acreate()method and inject through the constructor, exactly as for any other plugin (see Plugin API and attributes). -
The class is
finalbecause nothing should extend it; Drupal core does not require this.
Process plugins and pipelines
The process section assigns a value to each destination property. A property is written in one of three shapes:
process:
# 1. Shorthand for the "get" plugin: copy a source property.
title: title
# 2. One plugin with configuration.
langcode:
plugin: default_value
default_value: en
# 3. A pipeline: the output of each plugin is the input ("source") of the next.
material:
- plugin: get
source: material
- plugin: callback
callable: trim
A process plugin extends Drupal\migrate\ProcessPluginBase and implements transform($value, MigrateExecutableInterface
$migrate_executable, Row $row, $destination_property). It either returns the new value, or throws a skip exception to leave
the row (or only the property) out. The core skip tools are:
-
skip_on_emptywithmethod: rowskips the whole row, withmethod: processskips only the rest of that property’s pipeline. It accepts amessagethat is written to the message table for therowmethod. -
MigrateSkipRowExceptionthrown from a plugin skips the row; its second constructor argument says whether the skipped row is recorded in the map table as "ignored". -
MigrateExceptionmarks the row as failed and, unless it is a requirements error, lets the run continue with the next row.
The generic plugins used in this page:
| Plugin | What it does and its main keys |
|---|---|
|
Copies a source property or a constant. A list of source names, |
|
Returns the configured |
|
Maps input to output with |
|
Calls a PHP function named in |
|
Splits a string on |
|
Joins an array with |
|
Converts between date formats with |
|
Runs a nested pipeline over each item of an array, for compound fields. |
|
Looks up the destination ID that another migration produced for a source ID. |
|
Migrate Plus plugins that find (or create) an entity by a field value, for example a taxonomy term by name. |
|
Skips the row or the property as described above. |
The same tools are documented in the list of process plugins and in Process pipelines.
A custom process plugin
Two values of the Meridian CSV need domain logic: the accession number must be normalised and validated, and the free text
period must be turned into one of the allowed values of the period field. Both are small process plugins. They use the
Drupal\migrate\Attribute\MigrateProcess attribute, which replaced the @MigrateProcessPlugin annotation.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Plugin\migrate\process;
use Drupal\migrate\Attribute\MigrateProcess;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\MigrateSkipRowException;
use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\Row;
/**
* Normalises an accession number such as " mm.1987.0042 " to MM.1987.0042.
*
* A value that does not look like an accession number skips the row and is
* recorded in the migration's message table.
*/
#[MigrateProcess('meridian_accession_number')]
final class AccessionNumber extends ProcessPluginBase {
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property): string {
$normalised = strtoupper(trim((string) $value));
if (!preg_match('/^MM\.\d{4}\.\d{4}$/', $normalised)) {
throw new MigrateSkipRowException(sprintf('"%s" is not a valid accession number.', (string) $value));
}
return $normalised;
}
}
Source: Writing a process plugin
The second plugin needs a service, so it implements ContainerFactoryPluginInterface and receives the entity field manager by
constructor injection. It reads the allowed values from the entity type’s field definition instead of repeating them, so the
migration keeps working when a period is added.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Plugin\migrate\process;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\migrate\Attribute\MigrateProcess;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\MigrateSkipRowException;
use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\Row;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Turns a free text label into a key of a list field's allowed values.
*
* Configuration:
* - entity_type: The entity type id, for example collection_object.
* - field: The list field name, for example period.
*
* The input may be a key ("roman") or a label ("Industrial age"), in any
* case. An unknown value skips the property so the entity is saved without a
* period, and writes a message.
*/
#[MigrateProcess('meridian_allowed_value')]
final class AllowedValue extends ProcessPluginBase implements ContainerFactoryPluginInterface {
public function __construct(
array $configuration,
$plugin_id,
$plugin_definition,
private readonly EntityFieldManagerInterface $entityFieldManager,
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('entity_field.manager'),
);
}
/**
* {@inheritdoc}
*/
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property): ?string {
$definitions = $this->entityFieldManager->getBaseFieldDefinitions($this->configuration['entity_type']);
$allowed = $definitions[$this->configuration['field']]->getSetting('allowed_values');
$needle = mb_strtolower(trim((string) $value));
foreach ($allowed as $key => $label) {
if ($needle === mb_strtolower((string) $key) || $needle === mb_strtolower((string) $label)) {
return (string) $key;
}
}
$migrate_executable->saveMessage(sprintf('Unknown %s "%s".', $this->configuration['field'], (string) $value));
throw new MigrateSkipRowException('', FALSE);
}
}
Source: Writing a process plugin
A MigrateSkipRowException skips the row even when thrown inside a single property’s pipeline. To leave only one property
empty instead, return NULL for an unknown label. Which is right depends on the data rules: the Meridian registrar prefers to see the
row in the message table and fix the spreadsheet rather than import an object without a period.
The Migrate API calls the plugin once per row; do not cache anything in a property that must differ between rows. If a plugin
needs per-run state, such as a counter, read it from the executable ($migrate_executable) or from a service.
Destination plugins
A destination plugin writes the processed row. The destinations that matter in practice are:
| Destination | Notes |
|---|---|
|
Content entities and configuration entities, one derivative per entity type. For the Meridian entity it is
|
|
Provided by the Entity Reference Revisions module for paragraphs. |
|
Writes simple configuration objects. |
|
Writes rows to a database table that is not an entity. |
|
Does nothing, used by migrations that exist only to build a map. |
The entity destinations share configuration keys:
-
default_bundleis the bundle when the entity type has bundles and the row does not set one. -
validate: trueruns the entity’s constraint validation before saving and throws anEntityValidationExceptionfor a failing row, which marks that row as failed. The default isfalse, which means that a migration can create invalid entities (for instance one that violates theUniqueFieldconstraint on the accession number, or a required field left empty). Turn validation on for every migration that writes content people will edit. -
overwrite_propertieslists the destination properties that an update run may change. When it is present, every other property is left as the editors set it. -
translations: truetreats rows as translations of an existing entity (see below).
Two behaviours are easy to miss. A migration runs as the user who started it, which is the anonymous user in Drush unless the
--uid option is given, and the entity owner field takes that value as its default. The Meridian migration therefore runs
with --uid set to a curator account. Second, hooks and events fire exactly as for any entity save, so Search API indexing,
pathauto and the like run for every row; the official guidance is to disable modules that do heavy work on save before
a large import.
The Meridian migration
All the pieces together, as a plugin definition in the module:
# meridian_collection/migrations/meridian_collection_objects.yml
id: meridian_collection_objects
label: 'Meridian collection objects (CSV)'
migration_tags:
- Meridian
- Collection
source:
plugin: meridian_csv
path: 'private://meridian-import/objects.csv'
track_changes: true
process:
accession_number:
plugin: meridian_accession_number
source: accession_number
title:
- plugin: skip_on_empty
method: row
source: title
message: 'Row has no title'
- plugin: callback
callable: trim
'description/value': description
'description/format':
plugin: default_value
default_value: basic_html
period:
- plugin: skip_on_empty
method: process
source: period
- plugin: meridian_allowed_value
entity_type: collection_object
field: period
material: material
dimensions: dimensions
on_display:
plugin: static_map
source: on_display
map:
'yes': 1
'y': 1
'1': 1
'no': 0
'n': 0
'0': 0
default_value: 0
langcode:
plugin: default_value
default_value: en
destination:
plugin: 'entity:collection_object'
validate: true
overwrite_properties:
- accession_number
- title
- description
- period
- material
- dimensions
Reading the definition:
-
accession_numberis both the source identifier and a destination property. The custom plugin rejects the row without an accession number (the empty string is not a valid pattern) and normalisesmm.1987.0042toMM.1987.0042. -
titleusesskip_on_emptyfirst: a row with an empty title is skipped and the message is stored. -
The compound destination property
descriptionis filled by two entries,'description/value'and'description/format'. The key is quoted because it contains a slash, and the slash selects a sub-property of the field item. The formatbasic_htmlmust exist on the site (see Text formats and CKEditor 5). A migration does not run the text through a filter; it stores the string, and the format decides how it is filtered when rendered, so imported HTML must be restricted by the format and not trusted because it came from "our own" spreadsheet. -
on_displayaccepts three spellings of true and three of false. YAML turns an unquotedyesornointo a boolean, so the keys are quoted as strings; an unmapped value falls back to0. -
overwrite_propertiesleaveson_displayand any other property out. Curators decide what is on display in the site after the first import; a later import must not switch an object off just because the spreadsheet still saysno.
Run it against the sample file. The row without an accession number is skipped by the first process plugin and reported in the messages. The duplicated accession number must be removed from the source: the second row carries the same identifier as the first, and with track_changes on, its different values can be read as a change to the first object.
ddev drush migrate:status --tag=Meridian
ddev drush migrate:import meridian_collection_objects --limit=2 --uid=2
ddev drush migrate:import meridian_collection_objects --uid=2
ddev drush migrate:messages meridian_collection_objects
Source: Executing migrations
The first import with --limit=2 is the habit to keep: it shows problems on two rows before they happen on forty thousand.
migrate:messages lists what the pipeline recorded, here the rejected accession number.
Translations with a second migration
The Spanish text is delivered separately, in a file with accession_number, title and description. A translation is
migrated with the same entity destination, translations: true, and a mapping of the existing entity’s id and the language
(see Multilingual for how entity translations and the content translation module work).
# meridian_collection/migrations/meridian_collection_objects_es.yml
id: meridian_collection_objects_es
label: 'Meridian collection objects, Spanish translations'
migration_tags:
- Meridian
- Collection
migration_dependencies:
required:
- meridian_collection_objects
source:
plugin: meridian_csv
path: 'private://meridian-import/objects_es.csv'
track_changes: true
process:
id:
- plugin: meridian_accession_number
source: accession_number
- plugin: migration_lookup
migration: meridian_collection_objects
no_stub: true
- plugin: skip_on_empty
method: row
message: 'No English object exists for this accession number'
langcode:
plugin: default_value
default_value: es
title: title
'description/value': description
'description/format':
plugin: default_value
default_value: basic_html
destination:
plugin: 'entity:collection_object'
translations: true
validate: true
migration_lookup is how one migration refers to the output of another: it takes the accession number, finds the entity ID
that meridian_collection_objects stored in its map for that number, and returns it. That makes the dependency real, not
merely a convention, so --execute-dependencies runs the English migration first. With no_stub: true, an object that the
English migration skipped is not given a placeholder in the Spanish import. Without no_stub, the lookup would create a stub
through the migration named by stub_id, a default that surprises people.
The language module es and content translation for the collection_object entity type must be enabled for a translation
to be saved. A mismatch shows up as a MigrateException for each row.
Migration groups and shared configuration
Migrate Plus adds groups, which share configuration between members and give --group something to select. A group is a
configuration entity:
# meridian_collection/config/install/migrate_plus.migration_group.meridian.yml
id: meridian
label: 'Meridian Museum'
description: 'Imports from the registrar and the visitor services teams.'
source_type: 'CSV files in the private file system'
shared_configuration:
source:
track_changes: true
dependencies:
enforced:
module:
- meridian_collection
Source: Migrate Plus project page
A configuration-entity migration refers to the group with migration_group: meridian, and the shared configuration is merged
into its definition; the migration’s own keys win. Use groups to run the whole set (drush migrate:import --group=meridian)
and to avoid repeating a path, an API base URL or an authentication block.
Running migrations
With Migrate Tools the Drush commands are the interface. The names use a colon; the older hyphenated names (migrate-import,
migrate-rollback) and the short aliases mim and mr still work.
| Command | Purpose and notable options |
|---|---|
|
Lists migrations with total, imported and unprocessed counts, the last import time and the status (Idle, Importing, Rolling back, Stopping, Disabled). |
|
Runs migrations. Options: |
|
Reverses an import. Options: |
|
Shows the messages saved for a migration: skipped and failed rows with the source IDs. |
|
Asks a running migration to stop cleanly after the current row. |
|
Sets a migration that is stuck in Importing back to Idle after a crashed run. |
|
Lists the fields of the migration’s source plugin. |
Source: Migrate Tools project page
Migrate Tools implements its commands as Drush 13 and 14 style commands, with attributes and no drush.services.yml; a
module that adds its own migration command should follow the same pattern (see Drush). A
frequent pattern for a deployment is drush deploy followed by a targeted drush migrate:import --tag=Meridian --update; the
upstream guidance for scheduled runs is a real system cron job calling Drush rather than Drupal’s own cron, because a long
migration inside a web-triggered cron makes visitors wait (see
Cron, queues and maintenance). Disable modules that do expensive work
for each saved entity (Search API indexing on save is the usual one) before a large import and run an index afterwards.
Idempotency: running the same migration again
A migration is idempotent when running it twice produces the same site as running it once. The Migrate API gives you the mechanism; it does not guarantee the result, because the result depends on how the identifiers and properties are chosen.
The map and message tables
For every migration the API creates two tables, named from the migration ID: migrate_map_<id> and migrate_message_<id>.
-
The map table has one row per source row. Its columns are a hash of the source identifier values, the source ID columns (the ones declared by
getIds()), the destination ID columns, the status of the source row (imported, needs update, ignored, failed), the rollback action for the row (delete or preserve) and a hash of the source row’s values, used whentrack_changesis on. -
The message table has the messages that process plugins and the executable saved for a row.
The map is why a second migrate:import does nothing for rows already imported: the source row is found in the map, so the
destination is not asked to create it again. It is also what migration_lookup reads and what a rollback walks.
Choosing the identifier
The source identifier is the heart of idempotency. Choose one that is stable and unique in the source: the accession number
for Meridian, never the row number. If the registrar reorders the spreadsheet, a row-number key would remap every object onto
the wrong entity. If two rows share an identifier (the duplicated MM.2003.0211 in the sample), the outcome depends on the order of the rows and
on track_changes, so deduplicate or reject duplicates in the source plugin.
The destination id is separate. A migration that creates entities gets new entity IDs the first time. A migration that must attach to entities that already exist (for example because curators typed some objects by hand before the import) has no map entry for them, and would create duplicates. There are three remedies:
-
Look the existing entity up and map its ID into the destination
idproperty, withentity_lookupfrom Migrate Plus or a small custom process plugin; the destination then updates the entity instead of creating one. -
Let validation catch it:
validate: truewith theUniqueFieldconstraint onaccession_numbermakes the row fail with a clear message instead of creating a second object. -
Clear out the hand-made records first.
Updating changed rows
By default, a row that is already in the map is not touched again. The tools for picking up changes:
-
--updatere-processes every row in the map. It is correct and slow. -
track_changes: trueon the source stores a hash of each row. On the next run, a row whose hash changed is marked as needing an update and is processed, and unchanged rows are skipped. This is the usual choice for CSV and API sources, and it is what the Meridian migration uses. The hash comes from the source values afterprepareRow(), so a value added there changes it. -
high_water_propertyreads only rows whose property exceeds the highest value of the last run. It is efficient for large sources that have a trustworthy "changed" timestamp, but it cannot see changes that do not move the timestamp, and cannot be combined withtrack_changes. -
--sync(Migrate Tools) goes the other way: it removes destination records whose source row no longer exists.
An update re-runs the process pipeline and the destination with the new values, and overwrite_properties restricts which
destination properties are written. Without it, an update replaces everything the pipeline produces, including any edit a
curator made after the first import.
Idempotent processing
A process plugin must be a pure function of the row. A plugin that appends to a file, sends an email or increments a counter repeats its effect on each update. Where a side effect is unavoidable, make it conditional on the row being new, or move it out of the migration into a post-import event subscriber.
Rollback
Rolling back removes what a migration created. For each map row the executor calls the destination’s rollback() with the
stored destination IDs and removes the map row. Rows are processed in reverse order and dependent migrations should be rolled
back before the ones they depend on.
ddev drush migrate:rollback meridian_collection_objects_es
ddev drush migrate:rollback meridian_collection_objects
ddev drush migrate:rollback meridian_collection_objects --idlist=MM.1992.0108
ddev drush migrate:import meridian_collection_objects --update --uid=2
Source: Executing migrations
What rollback does, and what it does not:
-
The default rollback action is to delete the entity that the migration created. An update to a pre-existing entity (one that the destination found rather than created) records the action preserve, so a rollback leaves it alone and only forgets the mapping.
-
For translations, the destination removes the translation but never deletes the default translation.
-
Editors' later changes are not detected. A curator who edited an imported object and then rolls the migration back loses the edits together with the object. Content in production that people have worked on is rarely a candidate for rollback; take a backup first (see Backups and disaster recovery).
-
Anything a pipeline created outside the entity is not removed: files copied into the file system, terms generated by
entity_generate, external side effects. Create such objects in their own migrations so they can be rolled back on their own. -
The map and message tables are cleared by a full rollback, so the next import starts clean. If a run crashes and the migration shows Importing,
migrate:reset-statusreturns it to Idle.
The pair "rollback, fix the migration, import" is the normal development loop. In that loop the source and the YAML change, and the entities are rebuilt from scratch.
Running a migration from code
A migration is usually run from Drush, but a module can run one itself, for instance from a form for curators or from a
queue worker (see Queue, batch and cron APIs). The core classes are the
plugin.manager.migration service, which builds a migration from its ID, and Drupal\migrate\MigrateExecutable, which runs
it. The service below injects the plugin manager and a logger and exposes one method.
# meridian_collection/meridian_collection.services.yml (excerpt)
services:
meridian_collection.object_importer:
class: Drupal\meridian_collection\ObjectImporter
arguments:
- '@plugin.manager.migration'
- '@logger.channel.meridian_collection'
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection;
use Drupal\migrate\MigrateExecutable;
use Drupal\migrate\MigrateMessage;
use Drupal\migrate\Plugin\MigrationInterface;
use Drupal\migrate\Plugin\MigrationPluginManagerInterface;
use Psr\Log\LoggerInterface;
/**
* Runs the collection import from code.
*/
final class ObjectImporter {
public function __construct(
private readonly MigrationPluginManagerInterface $migrationManager,
private readonly LoggerInterface $logger,
) {}
/**
* Imports the CSV at the given URI and returns the migration result.
*
* @param string $uri
* A stream URI, for example private://meridian-import/objects.csv.
* @param bool $update
* TRUE to re-process rows that are already imported.
*
* @return int
* One of the MigrationInterface::RESULT_* constants.
*/
public function import(string $uri, bool $update = FALSE): int {
// The second argument overrides parts of the definition for this
// instance only.
$migration = $this->migrationManager->createInstance('meridian_collection_objects', [
'source' => ['path' => $uri],
]);
if ($update) {
// Marks the imported rows for update, like drush --update.
$migration->getIdMap()->prepareUpdate();
}
$executable = new MigrateExecutable($migration, new MigrateMessage());
$result = $executable->import();
if ($result !== MigrationInterface::RESULT_COMPLETED) {
$this->logger->error('The collection import ended with result @result.', ['@result' => $result]);
}
return $result;
}
}
Source: API reference: Migrate API
The constructor injects a plugin manager interface and a PSR logger. A \Drupal::service() call would work, but constructor
injection keeps the class testable and is the Drupal 11 convention. MigrateMessage prints messages through the
messenger, which suits a form submission; a queue worker or a Drush command would pass a message object that writes to a log
instead. The result constants (RESULT_COMPLETED, RESULT_INCOMPLETE, RESULT_FAILED, RESULT_SKIPPED, RESULT_DISABLED)
distinguish a finished run from one that was stopped or hit a problem.
Hooks and events around a migration
Migrate offers a hook and a set of Symfony events. The hook runs for every row after the source has read it:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Hook;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\migrate\Plugin\MigrateSourceInterface;
use Drupal\migrate\Plugin\MigrationInterface;
use Drupal\migrate\Row;
/**
* Adjusts source rows before the process pipelines run.
*/
final class MigrateHooks {
/**
* Implements hook_migrate_MIGRATION_ID_prepare_row().
*/
#[Hook('migrate_meridian_collection_objects_prepare_row')]
public function prepareObjectRow(Row $row, MigrateSourceInterface $source, MigrationInterface $migration): void {
// Collapse the white space that spreadsheets leave in long descriptions.
$description = (string) $row->getSourceProperty('description');
$row->setSourceProperty('description', preg_replace('/\s+/', ' ', trim($description)));
}
}
The class is picked up from src/Hook and autowired, as for any other hook class (see
Hooks and OOP hooks); the generic migrate_prepare_row hook would instead run for
every migration on the site. Hooks were declared as .module functions in older material.
The events are named in Drupal\migrate\Event\MigrateEvents: PRE_IMPORT, POST_IMPORT, PRE_ROW_SAVE, POST_ROW_SAVE,
PRE_ROLLBACK, POST_ROLLBACK, PRE_ROW_DELETE, POST_ROW_DELETE, MAP_SAVE, MAP_DELETE and IDMAP_MESSAGE. An event
subscriber (see Events and subscribers) can report a summary when an import ends:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\EventSubscriber;
use Drupal\migrate\Event\MigrateEvents;
use Drupal\migrate\Event\MigrateImportEvent;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Logs how many collection objects an import processed.
*/
final class ImportSummarySubscriber implements EventSubscriberInterface {
public function __construct(private readonly LoggerInterface $logger) {}
public static function getSubscribedEvents(): array {
return [MigrateEvents::POST_IMPORT => 'onPostImport'];
}
public function onPostImport(MigrateImportEvent $event): void {
$migration = $event->getMigration();
if ($migration->id() !== 'meridian_collection_objects') {
return;
}
$map = $migration->getIdMap();
$this->logger->notice('Collection import finished: @processed processed, @imported imported, @errors errors.', [
'@processed' => $map->processedCount(),
'@imported' => $map->importedCount(),
'@errors' => $map->errorCount(),
]);
}
}
Source: API reference: Migrate API
# meridian_collection/meridian_collection.services.yml (excerpt)
services:
meridian_collection.import_summary_subscriber:
class: Drupal\meridian_collection\EventSubscriber\ImportSummarySubscriber
arguments: ['@logger.channel.meridian_collection']
tags:
- { name: event_subscriber }
Source: Migrate API overview
Testing a migration
A migration is code and deserves tests. Core ships Drupal\Tests\migrate\Kernel\MigrateTestBase, a kernel test base class that
provides executeMigration() and collects the messages. The test below uses a small fixture file and checks the three things
that matter: the right rows land, a second run changes nothing, and a rollback removes them. PHPUnit 11 uses attributes for
metadata (see Automated testing).
<?php
declare(strict_types=1);
namespace Drupal\Tests\meridian_collection\Kernel;
use Drupal\filter\Entity\FilterFormat;
use Drupal\meridian_collection\Entity\CollectionObject;
use Drupal\migrate\MigrateExecutable;
use Drupal\Tests\migrate\Kernel\MigrateTestBase;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
/**
* Tests the CSV import of collection objects.
*/
#[Group('meridian_collection')]
#[RunTestsInSeparateProcesses]
final class ObjectsMigrationTest extends MigrateTestBase {
protected static $modules = [
'system',
'user',
'field',
'text',
'options',
'filter',
'migrate',
'meridian_collection',
];
protected function setUp(): void {
parent::setUp();
$this->installEntitySchema('user');
$this->installEntitySchema('collection_object');
FilterFormat::create(['format' => 'basic_html', 'name' => 'Basic HTML'])->save();
}
private function migration() {
// Point the migration at the fixture instead of the private file system.
return $this->container->get('plugin.manager.migration')->createInstance('meridian_collection_objects', [
'source' => ['path' => __DIR__ . '/../../fixtures/objects.csv'],
]);
}
public function testImportIsIdempotentAndReversible(): void {
$this->executeMigration($this->migration());
$first = CollectionObject::loadMultiple();
// The fixture holds two valid rows and one row without an accession
// number.
$this->assertCount(2, $first);
// A second run finds every row in the map and creates nothing.
$this->executeMigration($this->migration());
$this->assertCount(2, CollectionObject::loadMultiple());
(new MigrateExecutable($this->migration(), $this))->rollback();
$this->assertCount(0, CollectionObject::loadMultiple());
}
}
Source: Migration tests
createInstance() with an overriding array is how a test replaces the source path without editing the definition. The
fixture lives next to the test (tests/fixtures/objects.csv) and contains the smallest file that exercises every branch of the
pipeline. Kernel tests for migrations run in a few seconds; cover the custom process plugins separately with unit tests, and
the source plugin with a data provider of broken files (no header, empty file, missing column).
Security
-
Files. Put source files in the private file system or outside the web root. A CSV that sits under
sites/default/filesis downloadable, and it often holds data that is not yet public. A source path built from user input is a path traversal; take it from configuration and validate it. See File system and private files. -
Remote sources. The Migrate Plus
urlsource fetches a URL that comes from configuration. Keep API credentials in environment variables read insettings.php, never in the migration YAML, because migration definitions and exported migration configuration are committed. A URL that comes from a form or a request is a server-side request forgery risk. -
Trusting the data. The pipeline stores what it is given. Text that goes into a formatted field is rendered through the text format, so choose the format deliberately; do not use
full_htmlfor text from a source that people outside the museum can influence. Twig autoescapes plain text fields. -
CSV injection. A value starting with
=,+,-or@runs as a formula if somebody later exports the content to a spreadsheet. Clean such values in a process plugin when the source is not fully trusted. -
Access. A migration ignores entity access and the permission system, and runs as the user given by
--uid. Use a least-privileged service account for that option rather than user 1, and restrict who may run Drush on production. Content that moves through Content Moderation workflows is imported in whatever state the pipeline gives it (see Content moderation workflows and revisions), so set a moderation state deliberately. -
Validation. Set
validate: true. Without it the constraint that keeps accession numbers unique does not run. -
Secrets. Keep nothing secret in
track_changeshashes or messages: both are stored in the database.
See Security hardening for the site-wide measures.
Common errors and pitfalls
-
Editing a migration after its first import. Changing the
idabandons the map table; changing the source identifiers invalidates it. Roll back first, edit, then import again. -
The migration does not appear in
migrate:status. The module is not enabled, the file is not in the module’smigrations/directory, the plugin classes are not insrc/Plugin/migrate/{source,process,destination}, or caches were not rebuilt after adding a plugin or a definition. Rebuild withdrush cache:rebuild. -
"Plugin … was not found". A process or source ID does not match the ID in the attribute. The ID is the attribute’s
id(or its first argument), not the class name. -
Annotation copied from a tutorial. A plugin with a
@MigrateSourcedocblock and no attribute still works on Drupal 11, because the annotation classes remain, but new code uses attributes (see the version notes). -
Duplicate entities after a second import. The source ID changed, the map was cleared, or entities were created by hand and never mapped. See the idempotency section.
-
Status stuck at Importing. A run was killed. Run
migrate:reset-status. -
Booleans and numbers read as text. CSV values are strings.
static_mapandcallback: intvalconvert them, and an unquotedyesornoin a YAML map is a boolean, not a string. -
Memory. An iterator that reads the whole file into an array, or a migration that keeps all rows in memory through a
--batch-sizethat is too large, runs out of memory; read lazily as the custom source does, and import in batches. -
Slow imports. Entity save hooks, Search API, pathauto and revision creation add up. Disable what is not needed during the run, and prefer
track_changesto--updatefor repeat runs. -
Wrong user. Without
--uid, objects are owned by the anonymous user. -
Translations without the entity ID. A
translations: truedestination needs both anidand alangcodemapped, or the row fails.
Version notes and corrections of older material
-
The Migrate API is a core module and has been since Drupal 8. Plugin discovery by PHP attributes arrived in Drupal 10.3.0: the change record "Plugins converted from Annotations to Attributes in 10.3.0" lists the migrate attribute classes under
Drupal\migrate\Attribute, withMigrateDestinationandMigrateProcessin the table andMigrateSourcedocumented in the API reference alongside them. The removal version of the annotation classes is not stated in the change record. Older books such as Drupal 10 Development Cookbook (2023) show@MigrateSourceand@MigrateProcessPluginannotations and procedural hooks; both still run on Drupal 11, but new code uses#[MigrateSource],#[MigrateProcess],#[MigrateDestination]and OOP#[Hook]classes. -
Drush commands are attribute-based in Drush 13 and 14, and
drush.services.ymlno longer exists since Drush 13. Older tutorials that put aMigrateToolsCommands-style class indrush.services.ymldescribe Drush 8 to 11. -
The hyphenated Drush command names (
migrate-import) in older material still work as aliases; the canonical names aremigrate:import,migrate:rollbackandmigrate:status. -
EntityContentBasedeprecates constructing a destination without theentity_type.bundle.infoservice since Drupal 11.2, with the argument required in 12.0.0. Custom destination plugins that extend it should pass the service. -
Migrate Plus 6.0.10 is listed as working with Drupal ^10.5 || ^11 and Migrate Tools 6.1.4 with ^9.1 || ^10 || ^11. Drupal 10 ends on 2026-12-09, so a migration project started now targets Drupal 11.4.
-
Drupal 12. The Migrate Drupal and Migrate Drupal UI modules are removed from core with no contributed replacement, so a Drupal 7 site must be migrated to Drupal 11.x first. The generic Migrate API used by this page stays. The Layout Builder or Paragraphs to Canvas content move has no automatic tool; a migration written as on this page is the realistic route (see Canvas and visual page building).
-
The official documentation pages for Migrate are partly marked for Drupal 8 in their URLs and some are redirected to a new documentation host; the content still applies, and the canonical
drupal.org/docs/drupal-apis/migrate-apipaths are cited here. -
Other tools that overlap: the Feeds module, for editor-run recurring imports with a UI; Default Content, for small packs of entities exported from one site into a module or recipe (see Recipes); and plain
Entity::create()in a Drush script or an update function (see Update API and post-updates) for a one-off backfill. Use Migrate when you need mapping, re-runs and rollback; use the others when you do not.