2525 files changed, 24072 insertions(+), 141665 deletions(-)
This was a single drush cex after enabling one module.
The migration logic did not change. The data did not change. The configuration did not change. Only the UUIDs did.
Twenty-five hundred config YAML files. All touched. All showing a diff. None of the diffs meaningful. This is what random config UUIDs do to an iterative migration workflow.
The iterative migration workflow
If you have never done a large-scale Drupal 7-to-10 migration, here is the part nobody warns you about: it is not a one-shot operation. You do not write migration YAML, press a button, and watch content flow into the new site. Not on a real site with custom content types, multilingual content, legacy field formats, and a decade of accumulated data.
You run the migration. Inspect the output. Find that dates are off by one timezone. Adjust the process plugin. Run it again. Find that taxonomy terms lost their hierarchy. Fix the migration source query. Run it again. Find that body text references deleted files. Add a data normalization step. Run it again.
Multiple times per day. Sometimes a dozen times before lunch. The workflow looks like this:
┌─────────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ site:install │ ──▶ │ migrate │ ──▶ │ config │ ──▶ │ git diff │
│ (clean) │ │ (batch) │ │ export │ │ (verify) │
└─────────────┘ └──────────┘ └──────────┘ └──────────┘
▲ │
└─── adjust migration logic ◀─────────────────────────┘
Each cycle starts from a clean Drupal install. This is not optional. Migration plugins depend on a known initial state. If you run a migration on top of a previous migration run, you get duplicate content, orphaned references, and false-positive results. A clean slate is the only reliable starting point.
After the migration batch completes, you export the resulting configuration to the sync directory with drush cex. Then you inspect the git diff.
The git diff is the safety net. It tells you whether your change had the intended effect.
You adjust a process plugin. Re-run. Check the diff. Fix a field mapping. Re-run. Check the diff. Correct a data normalization issue. Re-run. Check the diff.
This cycle is tight, fast, and entirely dependent on the diff being readable. When you change one process plugin, you expect to see that change reflected in the exported config. Maybe a few related fields. A handful of files at most.
Not 2,525.
Where it breaks
Here is a real config file from a migration project. A block placement for the Claro admin theme, exported after a clean install:
uuid: 72dd84d9-8f9c-4377-9256-2fb214905976
langcode: da
status: true
dependencies:
theme:
- claro
_core:
default_config_hash: fNwDdW063tk_ktzSWzZVeQS9wzvLooVO280BQ9WrsIs
id: claro_page_title
theme: claro
region: header
weight: -30
provider: null
plugin: page_title_block
settings:
id: page_title_block
label: Sidetitel
label_display: '0'
provider: core
visibility: { }
Now run drush site:install again on the exact same codebase. Same commit. Same composer.lock. Same modules enabled. Export config again. Here is the same file:
uuid: ab0d2010-dc70-57f1-a4a8-590cc1463e7c
langcode: da
status: true
dependencies:
theme:
- claro
_core:
default_config_hash: fNwDdW063tk_ktzSWzZVeQS9wzvLooVO280BQ9WrsIs
id: claro_page_title
theme: claro
region: header
weight: -30
provider: null
plugin: page_title_block
settings:
id: page_title_block
label: Sidetitel
label_display: '0'
provider: core
visibility: { }
Every line is identical except the UUID on line 1. The config name is block.block.claro_page_title. The machine name did not change. The settings did not change. The dependencies did not change. But the UUID is different, so git diff flags the file as changed.
Multiply this by 850+ config files in a migration project and you get a diff that is functionally useless.
Someone will ask: "Config entities are keyed by machine name, not UUID. Why does the UUID matter?"
Three reasons.
First, the UUID is embedded in every config entity YAML file. It is always line 1. Every sync directory comparison sees it.
Second, config entities reference each other through dependency tracking. When a view depends on a block, or an image style references an image effect, UUID changes can cascade through those references.
Third, nested plugin configurations make it worse. Image style effects, view display handlers, filter format filters -- these use UUIDs as both the YAML mapping key and the identity value inside the mapping. A single image style with two effects has three UUIDs: one for the root config entity and one for each effect. All random. All different on every install.
The result: your diff is flooded with UUID noise. The actual migration changes are buried somewhere in the middle, indistinguishable from the UUID churn around them.
The snapshot trap
The first thing you try is database snapshots. If the install script has not changed since the last run, skip the install entirely and load a cached database dump instead. This is a standard optimization. Compute a checksum of the install script, use it as the snapshot filename, and restore from cache when possible.
Here is the approach I used:
SCRIPT="scripts/clean-install.sh"
SNAPSHOT_DIR="../db_snapshots"
# Compute SHA256 of the install script
if [[ "$CHK_CMD" == openssl* ]]; then
CHECKSUM=$(openssl dgst -sha256 "$SCRIPT" | sed -E "$CHK_FMT")
else
CHECKSUM=$($CHK_CMD "$SCRIPT" | awk "$CHK_FMT")
fi
SNAP_FILE="$SNAPSHOT_DIR/${CHECKSUM}.${EXT}"
if [ -f "$SNAP_FILE" ]; then
echo "Found snapshot for this version of $SCRIPT; importing..."
ddev import-db --file="$SNAP_FILE"
else
echo "No snapshot found. Running $SCRIPT..."
bash "$SCRIPT"
ddev export-db --file="$SNAP_FILE" --gzip
fi
The script computes SHA256(clean-install.sh) and uses the hash as the snapshot filename. If the install script has not changed, the cached database loads in seconds instead of minutes.
This makes repeated migration runs feasible. But it does not solve the UUID problem.
The moment you change anything in the install script -- add a module, adjust a site setting, update a dependency -- the checksum changes. The snapshot invalidates. You are back to a fresh drush site:install, which generates new random UUIDs for every config entity. Your next drush cex produces a diff where every file appears changed, even though the only difference is the UUID on line 1.
The snapshot buys you speed. It does not buy you stability.
And the install script evolves constantly during active migration development. You add a module to handle a new content type. You adjust a site setting for multilingual support. You update a dependency to fix a migration bug. Each change is small. Each change invalidates the snapshot. Each invalidation means a fresh install with fresh random UUIDs.
I used this approach for months. It helped with iteration speed on the days when the install script did not change. On the days when it did -- which was most days during active development -- it did nothing for diff reliability.
Why Drupal does this
Drupal's site:install command generates random version 4 UUIDs for every config entity. This is core's default behavior, and it is correct for most use cases.
Random UUIDs guarantee uniqueness across environments. Two separate Drupal installations will never produce colliding config UUIDs, which matters when you synchronize configuration between staging and production, or when you distribute configuration through install profiles and recipes. For the vast majority of Drupal sites -- where you install once and then manage configuration through import/export cycles -- random UUIDs work fine. You never notice them.
The problem appears only when you reinstall repeatedly as part of a development workflow. And specifically, when you rely on git diff of the exported config directory to verify your changes between iterations.
Most Drupal developers do not do this. Most Drupal developers do not need to. But migration developers do, because the install-migrate-export-diff loop is the fundamental workflow. There is no other reliable way to verify that a migration change had the intended effect without side effects.
Drupal behaves correctly for its primary audience. But iterative reinstall workflows expose a gap: the same config name, on the same codebase, produces a different UUID every time.
Scale of the problem
Here are real numbers from a production D7-to-D10 migration project:
- 281 total commits in the migration repository
- 850+ config YAML files in
config/sync - Multiple full reinstalls per day during active development
- The single UUID normalization commit: 2,525 files changed, 24,072 insertions, 141,665 deletions
That last number needs context. 141,665 deletions in a single commit. In a migration project, a commit that touches 2,525 files should represent a fundamental architectural change. A complete rethinking of the migration approach. A rewrite of the data model.
Instead, it was a UUID cleanup. No migration logic changed. No data mappings changed. No configuration semantics changed. The commit message says it all: the entire diff is UUID normalization. Nothing else.
When UUID noise consumes the entire diff, you lose the ability to review migration changes meaningfully. Code review becomes guesswork. Regression tracking becomes impossible. The safety net that makes iterative migration development viable -- the git diff -- stops working.
Think about what that means across the lifetime of the project. Every reinstall produces a new set of random UUIDs. Every config export after that reinstall touches every file. Every commit after that export buries the real changes under UUID noise. Over 281 commits, this compounds into a repository history where it is genuinely difficult to determine what changed and why.
Try running git log --stat on a repository like this. Commit after commit of hundreds or thousands of files changed. You cannot tell which commits contain actual migration logic changes and which are just UUID churn from a reinstall. You cannot use git bisect to find a regression. You cannot meaningfully review a pull request.
The requirement
The same config name, on the same codebase, should produce the same UUID. Every time.
block.block.claro_page_title should always get the same UUID, whether you install on Monday or Friday, on your laptop or in CI, on the first attempt or the fiftieth.
That is not how Drupal works today. And for most Drupal sites, it does not need to be. But for iterative migration workflows -- where reinstall-export-diff is the core development loop -- it is the difference between a usable safety net and 2,525 files of noise.
The module is available on drupal.org: config_uuid_deterministic. The development environment and full commit history are on GitLab: config_uuid_deterministic_development.
Next: Part 2: Making Drupal config UUIDs deterministic -- storage decorators, UUIDv5, and the edge cases that almost broke it.