Update API, Post-Updates and Deploy Hooks in Drupal Modules

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.

When the Meridian Museum developers change meridian_collection after the site is live, installing the new code is only half of the work. The database of the production site still has the old tables, the old configuration and the old content. The Update API is the mechanism that lets a module carry its own data migrations: functions that Drupal discovers, remembers, and runs exactly once per site, in a predictable order. This page covers the three kinds of update function (hook_update_N(), hook_post_update_NAME() and the Drush deploy hook), how to write batch updates, how to change database schema and entity definitions, how removed updates affect upgrades, and why Drupal 12 refuses to upgrade from a site older than 11.3.

The page was written against Drupal 11.4.x, Drush 13 and 14 and PHP 8.3 or later, and the facts were checked 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.

What the Update API is for

Configuration that is exported and imported (see Configuration management) covers changes to settings, but it cannot transform data that already exists. Three situations need code:

  • a database table or column of the module changes (for example the new provenance column of the meridian_collection_sync_log table);

  • stored content or configuration must be rewritten (for example every collection_object whose period field holds free text must be migrated to a taxonomy reference);

  • an entity type or field storage definition changes, and the site must be told that the stored schema is out of date.

Who uses it: the module developer writes the functions; the site administrator or the deployment pipeline runs them with drush updatedb or drush deploy, or from update.php. An editor never sees them, but a failed update leaves the whole site in maintenance-style "update required" state, so they are among the most security- and availability-sensitive code a module owns.

The three kinds of update function

Function File Runs Use for

hook_update_N()

meridian_collection.install

First, before configuration is imported, with a limited environment

Low-level schema changes, Database schema API calls, entity definition updates

hook_post_update_NAME()

meridian_collection.post_update.php

After all hook_update_N() functions, with Drupal fully bootstrapped

Rewriting content or configuration with the normal APIs

hook_deploy_NAME()

meridian_collection.deploy.php

By Drush, after configuration import

Site-specific data fixes that depend on the imported configuration

The Drush documentation describes the order explicitly. After the code is updated, drush deploy runs drush updatedb (which executes hook_update_N() and then hook_post_update_NAME()), drush config:import, drush cache:rebuild, drush deploy:hook and, from Drupal 11.2 on, drush cache:warm.

flowchart LR C[New code deployed by Composer] --> U[hook_update_N] U --> P[hook_post_update_NAME] P --> I[config:import] I --> R[cache:rebuild] R --> D[hook_deploy_NAME] D --> W[cache:warm]

All of these hooks are procedural. The OOP hook classes described in Hooks and OOP hooks (#[Hook] methods in src/Hook, Drupal 11.1 and later) do not apply: hook_install, hook_uninstall, hook_schema, hook_requirements, hook_update_N and hook_post_update_NAME must stay as functions in .install and .post_update.php files, and a conditionally defined update function is not supported.

hook_update_N: schema and low-level changes

Name the function meridian_collection_update_N() and put it in meridian_collection.install. Drupal stores, per module, the number of the last update that ran (the schema version). On each run it executes every function whose number is greater than the stored one, in ascending order, and stores the new number after each success.

Numbering

The API reference describes a number of three parts: one or two digits for the core compatibility (the lowest major core branch the module supports), one or two digits for the module major version and two digits for a sequential counter starting at 01. A module supporting Drupal 10.3 and later, in its first major version, therefore starts at 10101. Rules that follow from the way the number is stored:

  • N must be greater than the value of hook_update_last_removed(), the lowest recognised number is 8001, and a number ending in 000 is never valid.

  • Never renumber an update that has shipped. Sites record the number, not the content.

  • When the digit count grows, keep the order numeric: after 81001 use 90101, never 9101.

  • Core numbers its own updates in the same style. The Drupal documentation on schema changes warns not to pick a number that core itself might still need on a maintained branch: a number such as 10401 should not be used on a site where core is at 10.4, because a later core security release could carry an update with that number.

To see the stored version of the module, use the update.update_hook_registry service:

ddev drush php:eval "echo \Drupal::service('update.update_hook_registry')->getInstalledVersion('meridian_collection');"
ddev drush updatedb:status

Adding a column and keeping hook_schema in step

Updating a schema is always two steps: change hook_schema() so that new installs get the final structure, then write an update so that existing installs reach the same structure. The Meridian developers add a provenance column to the synchronisation log table.

<?php

/**
 * @file
 * Install, schema and update hooks for the Meridian collection module.
 */

use Drupal\Core\Database\Database;

/**
 * Implements hook_schema().
 */
function meridian_collection_schema(): array {
  $schema['meridian_collection_sync_log'] = [
    'description' => 'One row per object synchronised from the collections API.',
    'fields' => [
      'id' => ['type' => 'serial', 'not null' => TRUE],
      'accession_number' => ['type' => 'varchar', 'length' => 32, 'not null' => TRUE],
      'synced' => ['type' => 'int', 'not null' => TRUE, 'default' => 0],
      'provenance' => ['type' => 'varchar', 'length' => 255, 'not null' => FALSE],
    ],
    'primary key' => ['id'],
    'indexes' => ['accession_number' => ['accession_number']],
  ];
  return $schema;
}

/**
 * Adds the provenance column to the synchronisation log.
 */
function meridian_collection_update_10101(): string {
  $schema = Database::getConnection()->schema();
  if (!$schema->fieldExists('meridian_collection_sync_log', 'provenance')) {
    $schema->addField('meridian_collection_sync_log', 'provenance', [
      'type' => 'varchar',
      'length' => 255,
      'not null' => FALSE,
    ]);
  }
  return (string) t('Added the provenance column to the synchronisation log.');
}

Points worth noting in the example:

  • The update copies the column definition instead of calling hook_schema(). The documentation is explicit: a user who skips a release would run the update against a hook_schema() that has changed again, so the specification is frozen inside the function as it was when the update was written.

  • The fieldExists() guard makes the function safe to run on a site that already has the column (for example one restored from a newer backup). Defensive code is the norm because the function cannot know what other updates ran.

  • Constructor injection does not exist in procedural update functions. Database::getConnection() and the t() function are the accepted exceptions; services are otherwise reached through \Drupal::service() because these files are procedural by definition.

  • The same SQL concerns as in Database API and entity queries apply: only the Schema API, never a hand-written, driver-specific ALTER TABLE.

  • A field with data cannot freely change its specification. Shrinking a column or adding NOT NULL to a populated column can fail, and the documentation recommends a helper that also updates the last-installed field storage definition when the change concerns an entity field.

Other Schema API calls used in updates are createTable(), addIndex(), addUniqueKey(), addPrimaryKey(), changeField() and dropField().

Rules for the body of the function

The API reference for hook_update_N() gives guidelines that explain most update failures:

  • You cannot predict when an update runs, which updates ran before, or what the environment is. Write defensively.

  • Avoid loading, saving or deleting entities in hook_update_N(): those operations invoke hooks and services that may not match the stored schema. Entity work belongs in a post-update.

  • Retrieve and change entity and field definitions through entityDefinitionUpdateManager.

  • When saving configuration, pass trusted data ($has_trusted_data = TRUE) that matches the schema of the moment the update was written; configuration schema validation is not guaranteed to match the final schema.

  • Never rebuild the router in an update.

  • Safe operations are cache invalidation, configuration API calls with trusted data, marking the container for rebuild and entity definition updates through the definition update manager.

  • A message string returned from the function is shown to the administrator; returning nothing shows none.

  • Throw Drupal\Core\Utility\UpdateException with a user-meaningful message to fail an update. A failed query throws a PDOException.

Updating entity definitions

When meridian_collection adds a base field to the collection_object entity type (see Custom entity types), the field definition in code changes but the database does not. The status report shows "Entity/field definitions: Mismatched entity and/or field definitions" until an update installs the new storage.

use Drupal\Core\Field\BaseFieldDefinition;

/**
 * Installs the provenance base field on collection objects.
 */
function meridian_collection_update_10102(): void {
  $definition = BaseFieldDefinition::create('string')
    ->setLabel(t('Provenance'))
    ->setSetting('max_length', 255);

  \Drupal::entityDefinitionUpdateManager()
    ->installFieldStorageDefinition('provenance', 'collection_object', 'meridian_collection', $definition);
}

Never try to silence the mismatch with drush entity:updates: that command was removed from Drush long ago because it could destroy data automatically. Every change to a stored entity definition needs an explicit update function.

hook_post_update_NAME: data and configuration updates

A post-update function lives in meridian_collection.post_update.php, is named meridian_collection_post_update_NAME() and runs once all hook_update_N() functions have finished. Drupal is fully bootstrapped, so any API can be used: entity storage, the configuration factory, services. The name can be any machine name; there is no numeric order, and the functions are executed in the alphabetical order of their names, so give them a numeric prefix when order matters. Drupal records each name and never runs it twice.

Post-updates are skipped on module installation (they are marked as executed), so work that must happen on a fresh install belongs in hook_install() instead.

The Meridian curators decide that the free-text period field must become a reference to the period taxonomy. The function walks the objects in batches so that a collection of fifty thousand objects does not time out.

<?php

/**
 * @file
 * Post-update functions for the Meridian collection module.
 */

use Drupal\Core\Utility\UpdateException;

/**
 * Converts the free-text period of collection objects to a term reference.
 */
function meridian_collection_post_update_period_to_term(array &$sandbox): string {
  $storage = \Drupal::entityTypeManager()->getStorage('collection_object');

  if (!isset($sandbox['ids'])) {
    $sandbox['ids'] = array_values($storage->getQuery()
      ->accessCheck(FALSE)
      ->condition('period_text', '', '<>')
      ->sort('id')
      ->execute());
    $sandbox['total'] = count($sandbox['ids']);
    $sandbox['done'] = 0;
  }

  $ids = array_splice($sandbox['ids'], 0, 50);
  foreach ($storage->loadMultiple($ids) as $object) {
    $term = meridian_collection_find_or_create_period($object->get('period_text')->value);
    if ($term === NULL) {
      throw new UpdateException(sprintf('Cannot map the period of object %s.', $object->id()));
    }
    $object->set('period', $term);
    $object->save();
    $sandbox['done']++;
  }

  $sandbox['#finished'] = $sandbox['total'] === 0 ? 1 : $sandbox['done'] / $sandbox['total'];
  return (string) t('Converted @done of @total periods.', [
    '@done' => $sandbox['done'],
    '@total' => $sandbox['total'],
  ]);
}

The query calls accessCheck(FALSE) explicitly because an update runs without a user session and the ids are only used internally. Entity queries without an explicit accessCheck() throw an exception in current Drupal.

The sandbox and batch behaviour

Both hook_update_N() and hook_post_update_NAME() accept array &$sandbox. With the parameter present, the function behaves like a Batch API callback: it is called again and again until it sets $sandbox['#finished'] to 1. Initialise the state on the first call (when $sandbox is empty), process a bounded chunk, store progress in $sandbox and set #finished to a value between 0 and 1 on every pass. Do not store objects in the sandbox, only scalars and arrays of ids, because the sandbox is serialised between requests. The same technique is described for queues and batches in Queue, Batch and Cron APIs (that page is the reference for the Batch API itself).

Configuration updates

For configuration entities, core offers ConfigEntityUpdater, which loops over entities of one type in batches and saves those for which a callback returns TRUE. It is the right tool when the module changes the shape of a view, an editor or a text format it ships. Configuration that the module ships in config/install is not changed on existing sites by a new release; only an update function (or the site’s own configuration import) can do that.

use Drupal\Core\Config\Entity\ConfigEntityUpdater;
use Drupal\views\ViewEntityInterface;

/**
 * Switches the collection listing view to the new pager.
 */
function meridian_collection_post_update_listing_pager(array &$sandbox): void {
  $updater = \Drupal::classResolver(ConfigEntityUpdater::class);
  $updater->update($sandbox, 'view', function (ViewEntityInterface $view): bool {
    if ($view->id() !== 'collection_listing') {
      return FALSE;
    }
    $display = &$view->getDisplay('default');
    $display['display_options']['pager']['type'] = 'mini';
    return TRUE;
  });
}

Drush deploy hooks

hook_deploy_NAME() is provided by Drush, not by Drupal. It is defined in meridian_collection.deploy.php, is named meridian_collection_deploy_NAME() and runs during drush deploy:hook, which is the step after configuration import. That is the difference from a post-update: a post-update runs before the configuration of the new release has been imported, so it cannot rely on, for example, a new taxonomy vocabulary or a new field that the release exports. A deploy hook can. Like the other two kinds, a deploy hook can take array &$sandbox for batch work, can return a message string, and is executed only once per site.

<?php

/**
 * @file
 * Drush deploy hooks for the Meridian collection module.
 */

/**
 * Seeds the gallery vocabulary with the museum's standard galleries.
 */
function meridian_collection_deploy_seed_galleries(): string {
  $storage = \Drupal::entityTypeManager()->getStorage('taxonomy_term');
  foreach (['Atrium', 'Space Hall', 'Geology'] as $name) {
    if (!$storage->loadByProperties(['vid' => 'gallery', 'name' => $name])) {
      $storage->create(['vid' => 'gallery', 'name' => $name])->save();
    }
  }
  return 'Gallery terms are in place.';
}

Source: Drush: deploy

Deploy hooks work only where Drush is part of the pipeline; running update.php from the browser never runs them. Drush also offers drush deploy:hook-status to list pending deploy hooks and drush deploy:mark-complete to record them without executing, which is useful when a site is cloned from a database that already contains the data. The whole sequence is covered in Drush and the pipeline around it in Deployment and CI/CD.

The decision table for the Meridian developers:

Change Function to write

New column, new table, index

hook_update_N()

New base field on collection_object

hook_update_N() with the definition update manager

Rewrite many content entities

hook_post_update_NAME() with a sandbox

Change the shape of exported configuration the module owns

hook_post_update_NAME() with ConfigEntityUpdater

Create content that depends on the imported configuration

hook_deploy_NAME()

Work needed on a fresh install as well

hook_install(), plus an update for existing sites

Running updates

# Pending hook_update_N and post-update functions
ddev drush updatedb:status

# Run them
ddev drush updatedb

# The whole release sequence, with deploy hooks
ddev drush deploy

drush updatedb is also known as drush updb. The browser alternative is /update.php, which requires the site update_free_access setting to stay FALSE in settings.php so that only a user with the "Administer software updates" permission can run it. Leaving update_free_access at TRUE lets any visitor run update.php and is a security defect. The environment-specific settings are covered in settings.php and environments. Take a database backup before every run (see Backups and disaster recovery); updates are not transactional across statements and an interrupted run cannot be rolled back by Drupal.

Source: Drush: updatedb

Removed updates, equivalent updates and dependencies

Update functions accumulate forever unless a project removes them. Two hooks keep the system honest:

  • hook_update_last_removed() returns the highest hook_update_N() number that is no longer in the file. A site whose stored schema version is lower than that number cannot be updated by the module at all; Drupal blocks the update and asks for an intermediate release first. Core’s system module uses this to say "you must upgrade through the previous minor". New update numbers must be greater than the returned value.

  • hook_removed_post_updates() is the equivalent for post-updates. It goes in meridian_collection.post_update.php and returns an array whose keys are the names of removed post-update functions (such as meridian_collection_post_update_period_to_term) and whose values are the first stable version in which they were removed.

/**
 * Implements hook_removed_post_updates().
 */
function meridian_collection_removed_post_updates(): array {
  return [
    'meridian_collection_post_update_period_to_term' => '2.0.0',
  ];
}

When a fix is backported to several branches, use different numbers on each branch and mark the later update as an equivalent with the #[MarkFutureUpdateEquivalent] attribute, then write the later update defensively so it is skipped when its equivalent has already run. hook_update_dependencies() lets another module insert its updates between yours, so never assume that two of your updates run back to back.

The 11.3 minimum for upgrading to Drupal 12

Core uses hook_update_last_removed() too. Drupal 12 removes the update functions that exist only to reach 11.3, and therefore refuses to upgrade from 11.2 or earlier. The supported path is: update to the latest 11.x (11.3 at least, 11.4 or 11.5 recommended), run drush updatedb and drush deploy there until the site reports no pending updates, and only then switch to the 12 codebase. A site on Drupal 10 therefore goes first to 11.3 or later; Drupal 10 itself ends on 2026-12-09 and 10.6 is its last minor. See Upgrading Drupal 10 to 11 and Preparing for Drupal 12.

The same rule applies to the Meridian module itself: it should never remove an update that a supported release still needs. Drop old functions only in a new major version, bump hook_update_last_removed() in the same commit, and document in the release notes the release that sites must pass through.

Common errors and pitfalls

  • An update that uses entity APIs in hook_update_N(). The stored schema is not yet in sync, so loading or saving fails with missing-column errors. Move entity work to a post-update.

  • Calling hook_schema() inside an update. The result changes with every release; copy the definition.

  • Renumbering or reusing a number. The site believes the update already ran and skips it, or runs it twice.

  • Forgetting hook_schema(). Existing sites work after the update but new installs lack the column.

  • Relying on the order of post-updates by writing them in source order. They run alphabetically.

  • Storing objects in $sandbox. The batch crashes on serialisation; store ids.

  • Running updates against a database without a backup. An interrupted update leaves a half-converted site.

  • Treating a post-update as a deploy hook. Configuration of the new release is not imported yet.

  • Expecting the OOP hook classes to apply. #[Hook] does not apply to update functions.

  • Depending on drush entity:updates. It no longer exists, so write the definition update.

Older books and tutorials

Older books such as Drupal 10 Development Cookbook (2023) and Drupal 10 Masterclass (2023) present the Update API mostly as it was written for Drupal 8: hook_update_N() in .install and the Drush 9 and 10 era commands. The function names and the file locations still hold. What changed afterwards: the Drush @command annotations and drush.services.yml that those books show for custom commands are gone in Drush 13 (commands use PHP attributes and autowiring); drush deploy now also runs cache:warm from Drupal 11.2; the hooks that must stay procedural are the exact list given above even though the rest of a module moves to #[Hook] classes from 11.1; and every tutorial that tells you to upgrade a Drupal 10 site straight to a later major is out of date because Drupal 12 accepts only upgrades from 11.3 and later.