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.

flowchart LR F[(objects.csv)] --> S[Source plugin<br/>reads rows] S --> R[Row object<br/>source and destination values] R --> P[Process pipeline<br/>one chain per property] P --> D[Destination plugin<br/>saves the entity] D --> E[(collection_object<br/>entities)] D --> M[(Map table<br/>source ids to destination ids)] S -. hook_migrate_prepare_row .-> R

For each row the executor does the following:

  1. The source plugin yields an associative array of values and the declared source identifiers. The executor wraps it in a Drupal\migrate\Row.

  2. hook_migrate_prepare_row() (and the migration-specific variant) runs, and the source’s own prepareRow() can add or change source properties or skip the row.

  3. If the row is already in the map table with an unchanged hash and the run is not an update, the row is skipped.

  4. 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.

  5. The destination plugin receives the processed row, creates or updates the entity, validates it when asked to, and returns the destination identifiers.

  6. 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 (drupal/migrate_plus)

Migrations as configuration entities, migration groups with shared configuration, a url source with HTTP and file data fetchers and JSON, XML, SimpleXML and SOAP parsers, Basic, Digest and OAuth2 authentication, a table destination, the PREPARE_ROW event, and process plugins such as entity_lookup, entity_generate, file_blob, merge, skip_on_value, str_replace and transliteration

6.0.10 (23 January 2026), listed as working with Drupal ^10.5

^11

Migrate Tools (drupal/migrate_tools)

Drush commands migrate:import, migrate:rollback, migrate:status, migrate:messages, migrate:stop, migrate:reset-status, migrate:fields-source and migrate:tree

6.1.4 (13 May 2026), listed as working with Drupal ^9.1

^10

^11

Migrate Source CSV (drupal/migrate_source_csv)

A csv source plugin that reads delimited files

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

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

id

Machine name, unique on the site. It is part of the map table name (migrate_map_<id>), so keep it lower case with underscores, and never rename it after the first import.

label

Human readable name shown by migrate:status.

migration_group

Optional group (Migrate Plus). A group can supply shared configuration, such as one source path, to its members.

migration_tags

A list of free-form tags. drush migrate:import --tag=Meridian runs every migration carrying the tag.

migration_dependencies

Two lists, required and optional, of migration IDs that must run first. They are also used for ordering (--execute-dependencies).

source

The source plugin id (plugin) and its configuration, plus the generic keys track_changes, high_water_property, constants, cache_counts and skip_count.

process

A map of destination property to either a source property name (shorthand for the get plugin), a single plugin definition, or a list of plugin definitions applied in order.

destination

The destination plugin id and its configuration.

dependencies

Configuration dependencies, for example enforced: module: [meridian_collection] so the migration is removed with the module (relevant to configuration entities).

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 example meridian_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 the migrate module. 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 in config/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 at admin/config/development/configuration/single/import by choosing the "Migration" type, exported and edited at admin/config/development/configuration/single/export, and it supports migration groups. The price is that it is configuration: it travels through config:export and config: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

embedded_data

core migrate

Rows written into the definition itself (data_rows), used for tiny reference data and in tests

empty

core migrate

A migration with no rows, used for stubs and for dependencies that only need to exist

d7_*, d6_* and SqlBase subclasses

core migrate_drupal and migrate

Reading the tables of an old site through a second database connection

csv

Migrate Source CSV

Delimited files

url

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: true stores 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 with track_changes; the constructor throws a MigrateException if both are set.

  • cache_counts: true and skip_count: true control the row count that migrate:status shows, which can be expensive for a remote source.

  • constants holds fixed values that process pipelines reference as constants/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'

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 a max_length that matches the entity field (32 here) keeps the table narrow.

  • fields() is descriptive: it feeds migrate:fields-source and 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 a create() method and inject through the constructor, exactly as for any other plugin (see Plugin API and attributes).

  • The class is final because 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_empty with method: row skips the whole row, with method: process skips only the rest of that property’s pipeline. It accepts a message that is written to the message table for the row method.

  • MigrateSkipRowException thrown from a plugin skips the row; its second constructor argument says whether the skipped row is recorded in the map table as "ignored".

  • MigrateException marks 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

get

Copies a source property or a constant. A list of source names, source: [a, b], produces an array.

default_value

Returns the configured default_value as the property’s value, for fixed values such as the language.

static_map

Maps input to output with map; bypass: true returns the input when there is no match and default_value supplies a fallback. An unmapped value with neither throws a MigrateSkipRowException.

callback

Calls a PHP function named in callable on the value. unpack_source passes an array as separate arguments.

explode

Splits a string on delimiter, for multi-valued fields.

concat

Joins an array with delimiter.

format_date

Converts between date formats with from_format, to_format, from_timezone and to_timezone.

sub_process

Runs a nested pipeline over each item of an array, for compound fields.

migration_lookup

Looks up the destination ID that another migration produced for a source ID. migration names it, no_stub: true stops it from creating a placeholder entity when there is no match, stub_id names the migration that creates stubs.

entity_lookup, entity_generate

Migrate Plus plugins that find (or create) an entity by a field value, for example a taxonomy term by name.

skip_on_empty

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;
  }

}

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);
  }

}

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

entity:<entity_type>

Content entities and configuration entities, one derivative per entity type. For the Meridian entity it is entity:collection_object. It is also how nodes (entity:node), users, terms, files and media are migrated.

entity_reference_revisions:<type>

Provided by the Entity Reference Revisions module for paragraphs.

config

Writes simple configuration objects.

table (Migrate Plus)

Writes rows to a database table that is not an entity.

null

Does nothing, used by migrations that exist only to build a map.

The entity destinations share configuration keys:

  • default_bundle is the bundle when the entity type has bundles and the row does not set one.

  • validate: true runs the entity’s constraint validation before saving and throws an EntityValidationException for a failing row, which marks that row as failed. The default is false, which means that a migration can create invalid entities (for instance one that violates the UniqueField constraint on the accession number, or a required field left empty). Turn validation on for every migration that writes content people will edit.

  • overwrite_properties lists the destination properties that an update run may change. When it is present, every other property is left as the editors set it.

  • translations: true treats 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_number is 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 normalises mm.1987.0042 to MM.1987.0042.

  • title uses skip_on_empty first: a row with an empty title is skipped and the message is stored.

  • The compound destination property description is 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 format basic_html must 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_display accepts three spellings of true and three of false. YAML turns an unquoted yes or no into a boolean, so the keys are quoted as strings; an unmapped value falls back to 0.

  • overwrite_properties leaves on_display and 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 says no.

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

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

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

migrate:status

Lists migrations with total, imported and unprocessed counts, the last import time and the status (Idle, Importing, Rolling back, Stopping, Disabled).

migrate:import

Runs migrations. Options: --all, --group, --tag, --limit (items per migration), --idlist (only these source IDs), --update (re-process rows already imported), --sync (delete destination records whose source row has gone), --force (ignore unsatisfied dependencies), --execute-dependencies, --continue-on-failure, --feedback, --batch-size, --skip-progress-bar and --uid (run as that user).

migrate:rollback

Reverses an import. Options: --all, --group, --tag, --idlist, --feedback, --continue-on-failure, --skip-progress-bar. It has no --limit, --update or --sync.

migrate:messages

Shows the messages saved for a migration: skipped and failed rows with the source IDs.

migrate:stop

Asks a running migration to stop cleanly after the current row.

migrate:reset-status

Sets a migration that is stuck in Importing back to Idle after a crashed run.

migrate:fields-source

Lists the fields of the migration’s source plugin.

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 when track_changes is 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.

flowchart TD A[Read source row] --> B{Source ID in map?} B -- no --> N[Process the row and save a new entity] N --> W[Write source ID, destination ID and row hash to the map] B -- yes --> C{Update requested, or row marked needs update?} C -- no --> K[Skip: already imported] C -- yes --> H{track_changes on and hash unchanged?} H -- yes --> K H -- no --> U[Process the row and update the entity] U --> V[Only overwrite_properties change] V --> W

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 id property, with entity_lookup from Migrate Plus or a small custom process plugin; the destination then updates the entity instead of creating one.

  • Let validation catch it: validate: true with the UniqueField constraint on accession_number makes 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:

  • --update re-processes every row in the map. It is correct and slow.

  • track_changes: true on 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 after prepareRow(), so a value added there changes it.

  • high_water_property reads 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 with track_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

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-status returns 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;
  }

}

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(),
    ]);
  }

}
# 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 }

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/files is 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 url source fetches a URL that comes from configuration. Keep API credentials in environment variables read in settings.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_html for 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_changes hashes 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 id abandons 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’s migrations/ directory, the plugin classes are not in src/Plugin/migrate/{source,process,destination}, or caches were not rebuilt after adding a plugin or a definition. Rebuild with drush 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 @MigrateSource docblock 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_map and callback: intval convert them, and an unquoted yes or no in 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-size that 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_changes to --update for repeat runs.

  • Wrong user. Without --uid, objects are owned by the anonymous user.

  • Translations without the entity ID. A translations: true destination needs both an id and a langcode mapped, 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, with MigrateDestination and MigrateProcess in the table and MigrateSource documented 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 @MigrateSource and @MigrateProcessPlugin annotations 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.yml no longer exists since Drush 13. Older tutorials that put a MigrateToolsCommands-style class in drush.services.yml describe Drush 8 to 11.

  • The hyphenated Drush command names (migrate-import) in older material still work as aliases; the canonical names are migrate:import, migrate:rollback and migrate:status.

  • EntityContentBase deprecates constructing a destination without the entity_type.bundle.info service 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-api paths 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.