Skip to main content

Migration, Versioning, and Rollback Playbooks

Reliable ACF migrations are release workflows, not one-time scripts.

Learning Focus

You will design migration playbooks that combine schema version tracking, idempotent data transforms, and rollback commands so ACF Pro changes can be released and reversed with confidence.

ACF Pro Required

This lesson uses ACF Pro-focused workflows, including Local JSON delivery patterns and complex field migrations with Repeater or Flexible Content structures.

Concept Overview

ACF migrations usually involve two moving parts at once: schema changes and content value moves. Schema changes include renamed fields, new field groups, adjusted return formats, and modified location rules. Content value moves include copying old values into new fields, reshaping arrays, and cleaning stale metadata. When teams treat migration as only a code deploy, they miss the data lifecycle and create silent production drift.

Versioning is the coordination layer that prevents repeated or partial application. A migration should be keyed to an explicit schema version option, run idempotently, and emit measurable results. Idempotency means the same migration command can run again without duplicating writes or destroying values. Without that property, retries after deployment interruptions become dangerous.

Rollback planning belongs in the first draft of migration code, not in incident response. You need a backup signal per migrated post, a machine-readable list of touched IDs, and a command that can restore pre-migration values. This should be validated in staging with representative records before production release. A rollback that exists only as a vague idea is not a rollback.

For enterprise ACF projects, Local JSON should be part of this same playbook. You should verify field group fingerprints before migration starts, then confirm no unexpected schema drift after deployment. That gives you a reproducible chain from repository state to runtime registry.

Core Idea

Ship migration as a repeatable pipeline: verify schema state, run idempotent transforms, record exactly what changed, and keep rollback executable at command level.

Why It Matters

ApproachWhat HappensImpact in Production
Version-gated idempotent migrationsCommands can be retried safely after interruptionsLower release risk and cleaner incident recovery
Save backup meta before writing new valuesRollback can restore exact pre-migration contentFaster mitigation during failed releases
Store touched post IDs in option logTeams know exactly what changed and whereTargeted audits instead of broad manual review
Compare schema fingerprint before and after releaseUnexpected field-group drift is caught earlyBetter configuration integrity across environments
Run direct destructive migration with no dry run (wrong pattern)In-place overwrite may lose legacy valuesData loss risk and long recovery windows

Reference Table

Term/APISignature/SyntaxPurposeKey Notes
acf/settings/save_jsonadd_filter('acf/settings/save_json', callable $callback, int $priority = 10, int $accepted_args = 1): stringSet canonical Local JSON export pathKeep path consistent across team environments
acf/settings/load_jsonadd_filter('acf/settings/load_json', callable $callback, int $priority = 10, int $accepted_args = 1): arrayAdd one or more Local JSON import pathsSupports shared package or release-candidate paths
acf_get_field_groups()acf_get_field_groups(array $filter = []): arrayRead runtime-registered field groupsUseful for schema baseline and drift checks
get_field()`get_field(string $selector, intstring $post_id = false, bool $format_value = true): mixed`Read source value during migration
update_field()`update_field(string $selector, mixed $value, intstring $post_id = false): intbool`
WP_Query ID batchnew WP_Query(['post_type' => 'page', 'fields' => 'ids', 'posts_per_page' => 200, 'no_found_rows' => true])Scan large content sets efficientlyReduces memory and query overhead
update_option()`update_option(string $option, mixed $value, boolstring $autoload = null): bool`Persist schema version and migration logs
wp eval-filewp eval-file <path>Run migration script from CLIGood for controlled one-off operations
wp db exportwp db export <file>Create pre-migration backup snapshotMandatory for high-risk transformations
wp db importwp db import <file>Restore previous DB state in full rollbackUse only with approved incident playbook
ACF Pro Required

Rows involving Local JSON and migrations of Pro field structures such as Repeater or Flexible Content require ACF Pro workflows.

Practical Use Cases

1. Idempotent hero field rename migration with schema version gate

Your team is replacing hero_title with service_headline across service pages. Some pages already have the new field populated, and migration must skip those records. You need a dry run mode, backup meta for changed rows, and an option-based schema version marker.

  1. Register a WP-CLI command from acf/init so it runs only when ACF is available.
  2. Query page IDs in predictable batches.
  3. Copy value only when old field is non-empty and new field is empty.
  4. Save backup value before write and store touched IDs for audit.
  5. Update clinic_acf_schema_version after successful live run.
wp-content/mu-plugins/clinic-acf-migration-hero-headline.php
<?php

declare(strict_types=1);

add_action('acf/init', function (): void {
if (!defined('WP_CLI') || !WP_CLI || !class_exists('WP_CLI')) {
return;
}

WP_CLI::add_command('clinic-acf migrate-hero-headline', function (array $args, array $assocArgs): void {
$dryRun = isset($assocArgs['dry-run']) ? ((int) $assocArgs['dry-run'] === 1) : true;
$batch = isset($assocArgs['batch']) ? max(1, (int) $assocArgs['batch']) : 200;

$query = new WP_Query([
'post_type' => 'page',
'post_status' => 'publish',
'fields' => 'ids',
'posts_per_page' => $batch,
'no_found_rows' => true,
'meta_query' => [
[
'key' => '_wp_page_template',
'value' => 'templates/service-landing.php',
],
],
]);

$scanned = 0;
$migrated = 0;
$skipped = 0;
$touched = [];

foreach ((array) $query->posts as $rawId) {
$postId = (int) $rawId;
$scanned++;

$legacy = trim((string) get_field('hero_title', $postId));
$target = trim((string) get_field('service_headline', $postId));

if ($legacy === '' || $target !== '') {
$skipped++;
continue;
}

if ($dryRun) {
$migrated++;
continue;
}

update_post_meta($postId, '_migration_v2026_02_hero_title_backup', $legacy);
update_field('service_headline', $legacy, $postId);
$touched[] = $postId;
$migrated++;
}

WP_CLI::log('Scanned: ' . $scanned);
WP_CLI::log(($dryRun ? 'Would migrate: ' : 'Migrated: ') . $migrated);
WP_CLI::log('Skipped: ' . $skipped);

if (!$dryRun) {
update_option('clinic_acf_migration_v2026_02_hero_headline_ids', $touched, false);
update_option('clinic_acf_schema_version', '2026.02.0', false);
WP_CLI::success('Updated schema version to 2026.02.0.');
return;
}

WP_CLI::success('Dry run complete.');
});
});
terminal: command
wp clinic-acf migrate-hero-headline --dry-run=1 --batch=200
wp clinic-acf migrate-hero-headline --dry-run=0 --batch=200
wp option get clinic_acf_schema_version
wp post meta get 1204 _migration_v2026_02_hero_title_backup
terminal: output
Scanned: 200
Would migrate: 37
Skipped: 163
Success: Dry run complete.
Scanned: 200
Migrated: 37
Skipped: 163
Success: Updated schema version to 2026.02.0.
2026.02.0
Permanent Hair Reduction Program
warning

Never run the live command before dry run output is reviewed by another engineer. Dry run counts are your first safety gate.

2. Local JSON schema fingerprint audit before and after migration

A deployment pipeline promotes ACF field groups through repository commits. You need a machine-readable fingerprint to confirm runtime schema matches committed JSON. This prevents running data transforms against an unexpected field registry.

  1. Set explicit save and load paths for Local JSON.
  2. Register a CLI command that calculates a deterministic fingerprint from JSON files.
  3. Compare current fingerprint and group count against saved baseline.
  4. Optionally write baseline during release preparation.
  5. Block migration steps when mismatch is detected.
wp-content/themes/clinic-headless/inc/acf/schema-fingerprint-audit.php
<?php

declare(strict_types=1);

add_filter('acf/settings/save_json', function (string $path): string {
return get_stylesheet_directory() . '/acf-json';
}, 10, 1);

add_filter('acf/settings/load_json', function (array $paths): array {
$paths[] = get_stylesheet_directory() . '/acf-json';
$paths[] = WP_CONTENT_DIR . '/acf-json-release-candidate';
return array_values(array_unique($paths));
}, 10, 1);

add_action('acf/init', function (): void {
if (!defined('WP_CLI') || !WP_CLI || !class_exists('WP_CLI')) {
return;
}

WP_CLI::add_command('clinic-acf schema-audit', function (array $args, array $assocArgs): void {
$writeBaseline = isset($assocArgs['write-baseline']) ? ((int) $assocArgs['write-baseline'] === 1) : false;

$jsonPath = get_stylesheet_directory() . '/acf-json';
$files = glob($jsonPath . '/*.json');
$files = is_array($files) ? $files : [];
sort($files);

$parts = [];
foreach ($files as $file) {
$contents = file_get_contents($file);
$parts[] = basename($file) . ':' . hash('sha256', (string) $contents);
}

$fingerprint = hash('sha256', implode('|', $parts));
$groups = acf_get_field_groups();
$groupCount = is_array($groups) ? count($groups) : 0;

$current = [
'fingerprint' => $fingerprint,
'group_count' => $groupCount,
'generated_at' => gmdate('c'),
];

if ($writeBaseline) {
update_option('clinic_acf_schema_fingerprint', $current, false);
WP_CLI::log('groups=' . $groupCount);
WP_CLI::log('fingerprint=' . $fingerprint);
WP_CLI::success('Schema baseline saved.');
return;
}

$baseline = get_option('clinic_acf_schema_fingerprint', []);
$baselineFp = is_array($baseline) ? (string) ($baseline['fingerprint'] ?? '') : '';
$baselineCount = is_array($baseline) ? (int) ($baseline['group_count'] ?? 0) : 0;

WP_CLI::log('groups=' . $groupCount);
WP_CLI::log('fingerprint=' . $fingerprint);

if ($baselineFp === '' || $baselineCount === 0) {
WP_CLI::warning('No baseline found. Run with --write-baseline=1 first.');
return;
}

if ($baselineFp !== $fingerprint || $baselineCount !== $groupCount) {
WP_CLI::error('Schema mismatch detected. Stop migration and reconcile Local JSON.');
return;
}

WP_CLI::success('Schema audit passed.');
});
});
terminal: command
wp clinic-acf schema-audit --write-baseline=1
wp clinic-acf schema-audit
wp option get clinic_acf_schema_fingerprint --format=json
ls wp-content/themes/clinic-headless/acf-json/
terminal: output
groups=18
fingerprint=ca1848e6c4a9fbf9d2dd25521269f8486639fc7675d675c891f2eb5b8b3f4b60
Success: Schema baseline saved.
groups=18
fingerprint=ca1848e6c4a9fbf9d2dd25521269f8486639fc7675d675c891f2eb5b8b3f4b60
Success: Schema audit passed.
{"fingerprint":"ca1848e6c4a9fbf9d2dd25521269f8486639fc7675d675c891f2eb5b8b3f4b60","group_count":18,"generated_at":"2026-02-23T19:14:40+00:00"}
group_64f1a100ad31.json
group_64f1a2a9f5f2.json
group_64f1a5e4a762.json
note

Schema fingerprint mismatch does not always mean bad code. It can indicate uncommitted JSON, wrong branch, or environment-specific path drift. Treat it as a release stop signal until explained.

3. Edge case: destructive overwrite during rich-text-to-repeater migration

A team migrates case_summary plain text into a new Repeater case_summary_blocks. A naive script overwrites records every run and keeps no backup. When the new rendering fails for some posts, there is no safe recovery path.

Fragile Pattern (Bad)

wp-content/mu-plugins/clinic-acf-migration-case-summary-fragile.php
<?php

declare(strict_types=1);

add_action('acf/init', function (): void {
if (!defined('WP_CLI') || !WP_CLI || !class_exists('WP_CLI')) {
return;
}

WP_CLI::add_command('clinic-acf migrate-case-summary-fragile', function (): void {
$ids = get_posts(['post_type' => 'case_study', 'post_status' => 'publish', 'fields' => 'ids', 'numberposts' => -1]);
foreach ((array) $ids as $id) {
$text = (string) get_field('case_summary', (int) $id);
update_field('case_summary_blocks', [['case_summary_block_text' => $text]], (int) $id);
}
WP_CLI::success('Migration complete.');
});
});

Robust Pattern (Good)

wp-content/mu-plugins/clinic-acf-migration-case-summary-v2.php
<?php

declare(strict_types=1);

add_action('acf/init', function (): void {
if (!defined('WP_CLI') || !WP_CLI || !class_exists('WP_CLI')) {
return;
}

WP_CLI::add_command('clinic-acf migrate-case-summary-v2', function (array $args, array $assocArgs): void {
$dryRun = isset($assocArgs['dry-run']) ? ((int) $assocArgs['dry-run'] === 1) : true;

$query = new WP_Query([
'post_type' => 'case_study',
'post_status' => 'publish',
'fields' => 'ids',
'posts_per_page' => -1,
'no_found_rows' => true,
]);

$migrated = 0;
$skipped = 0;
$touchedIds = [];

foreach ((array) $query->posts as $rawId) {
$postId = (int) $rawId;
$legacy = trim((string) get_field('case_summary', $postId));
$blocks = get_field('case_summary_blocks', $postId, false);
$hasBlocks = is_array($blocks) && $blocks !== [];

if ($legacy === '' || $hasBlocks) {
$skipped++;
continue;
}

if ($dryRun) {
$migrated++;
continue;
}

update_post_meta($postId, '_backup_case_summary_v1', $legacy);
$payload = [
[
'case_summary_block_title' => 'Summary',
'case_summary_block_text' => $legacy,
],
];

update_field('case_summary_blocks', $payload, $postId);
update_post_meta($postId, '_migration_case_summary_v2_applied', '1');
$touchedIds[] = $postId;
$migrated++;
}

WP_CLI::log(($dryRun ? 'Would migrate: ' : 'Migrated: ') . $migrated);
WP_CLI::log('Skipped: ' . $skipped);

if (!$dryRun) {
update_option('clinic_acf_migration_case_summary_v2_ids', $touchedIds, false);
update_option('clinic_acf_migration_case_summary_v2_completed_at', gmdate('c'), false);
}

WP_CLI::success($dryRun ? 'Dry run complete.' : 'Migration complete.');
});

WP_CLI::add_command('clinic-acf rollback-case-summary-v2', function (array $args, array $assocArgs): void {
$singlePostId = isset($assocArgs['post_id']) ? (int) $assocArgs['post_id'] : 0;
$targets = [];

if ($singlePostId > 0) {
$targets[] = $singlePostId;
} else {
$saved = get_option('clinic_acf_migration_case_summary_v2_ids', []);
$targets = array_map('intval', is_array($saved) ? $saved : []);
}

$restored = 0;
foreach ($targets as $postId) {
$backup = (string) get_post_meta($postId, '_backup_case_summary_v1', true);
if ($backup === '') {
continue;
}

update_field('case_summary', $backup, $postId);
update_field('case_summary_blocks', [], $postId);
delete_post_meta($postId, '_migration_case_summary_v2_applied');
$restored++;
}

WP_CLI::success('Rollback restored ' . $restored . ' posts.');
});
});
terminal: command
wp clinic-acf migrate-case-summary-v2 --dry-run=1
wp clinic-acf migrate-case-summary-v2 --dry-run=0
wp post meta get 991 _backup_case_summary_v1
wp clinic-acf rollback-case-summary-v2 --post_id=991
wp eval 'var_export(get_field("case_summary", 991)); echo PHP_EOL;'
terminal: output
Would migrate: 54
Skipped: 312
Success: Dry run complete.
Migrated: 54
Skipped: 312
Success: Migration complete.
Candidate improved skin texture score by 37 percent in 8 weeks.
Success: Rollback restored 1 posts.
'Candidate improved skin texture score by 37 percent in 8 weeks.'
warning

Do not clear backup meta in the same deployment as migration. Keep backups until the new rendering path has passed production monitoring thresholds.

Common Mistakes

MistakeRoot CauseWhat Breaks in ProductionCorrect Pattern
Live migration executed before dry run reviewDelivery pressure and no checklist gateUnexpected write volume and silent bad mappingswp clinic-acf migrate-hero-headline --dry-run=1 --batch=200
No per-record backup before target writeAssumes migration will always succeedRollback cannot restore original valuesupdate_post_meta($postId, '_backup_case_summary_v1', $legacy);
Schema version not persistedNo state gate for re-runsSame migration may run repeatedly on deploywp option get clinic_acf_schema_version
Local JSON drift ignoredRuntime field groups differ from repoMigration targets wrong field keys or nameswp clinic-acf schema-audit
Rollback path untested in stagingTeam focuses only on forward successIncident response time increases sharplywp clinic-acf rollback-case-summary-v2 --post_id=991
Migration logs not retainedTouched IDs never recordedAudits and selective fixes become manualwp option get clinic_acf_migration_case_summary_v2_ids --format=json
Deep Dive: Why Field Key Drift Is Harder to Debug Than It Looks

Field key drift appears when one environment has updated JSON while another still registers older field definitions. A migration may still run, but values can land in unexpected meta keys or fail to populate UI as expected. This bug is subtle because command output can look successful while editors report missing values later. The fastest way to detect drift is comparing runtime group keys and your stored schema fingerprint before migration. When the fingerprint differs, pause release and reconcile JSON sources first.

wp eval 'print_r(array_map(static fn($g) => $g["key"], acf_get_field_groups()));'

Best Practices

  1. Keep one release schema option and verify each deploy with wp option get clinic_acf_schema_version.
  2. Require dry run output in deployment notes: wp clinic-acf migrate-case-summary-v2 --dry-run=1.
  3. Save touched IDs for every migration using update_option('clinic_acf_migration_<id>_ids', $ids, false);.
  4. Validate Local JSON fingerprint before running data writes: wp clinic-acf schema-audit.
  5. Back up old field value before each write using update_post_meta($postId, '_backup_<field>', $oldValue);.
  6. Keep rollback command executable per record and batch mode in the same release artifact.
  7. Review migrated sample records with CLI spot checks such as wp post meta get 991 _backup_case_summary_v1.

Hands-On Practice

Exercise 1: Create migration drill records

Run:

wp post create --post_title="Case Migration Drill 1" --post_status=publish --post_type=case_study
wp post create --post_title="Case Migration Drill 2" --post_status=publish --post_type=case_study

After completing this exercise, running wp post list --post_type=case_study --search="Case Migration Drill" --fields=ID,post_title --format=table should return two rows.

Exercise 2: Seed legacy field values for migration

Run:

wp eval '$id=(int)get_page_by_title("Case Migration Drill 1", OBJECT, "case_study")->ID; update_field("case_summary","Legacy summary one",$id); echo $id . PHP_EOL;'
wp eval '$id=(int)get_page_by_title("Case Migration Drill 2", OBJECT, "case_study")->ID; update_field("case_summary","Legacy summary two",$id); echo $id . PHP_EOL;'

After completing this exercise, running wp eval '$id=(int)get_page_by_title("Case Migration Drill 1", OBJECT, "case_study")->ID; var_export(get_field("case_summary",$id)); echo PHP_EOL;' should return Legacy summary one.

Exercise 3: Run dry run and confirm candidate count

Run:

wp clinic-acf migrate-case-summary-v2 --dry-run=1

After completing this exercise, output should include:

Would migrate:
Skipped:
Success: Dry run complete.

Exercise 4: Execute live migration and verify backup meta

Run:

wp clinic-acf migrate-case-summary-v2 --dry-run=0
wp eval '$id=(int)get_page_by_title("Case Migration Drill 1", OBJECT, "case_study")->ID; echo get_post_meta($id,"_backup_case_summary_v1",true) . PHP_EOL;'

After completing this exercise, output should include:

Success: Migration complete.
Legacy summary one

Exercise 5: Perform targeted rollback validation

Run:

wp eval '$id=(int)get_page_by_title("Case Migration Drill 1", OBJECT, "case_study")->ID; echo $id . PHP_EOL;'
wp clinic-acf rollback-case-summary-v2 --post_id=<returned-id>
wp eval '$id=(int)get_page_by_title("Case Migration Drill 1", OBJECT, "case_study")->ID; var_export(get_field("case_summary",$id)); echo PHP_EOL;'

After completing this exercise, output should include:

Success: Rollback restored 1 posts.
'Legacy summary one'

CLI Reference

CommandPurposeReal Example Output
wp clinic-acf migrate-hero-headline --dry-run=1 --batch=200Preview rename migration effectScanned: 200 Would migrate: 37 Skipped: 163
wp clinic-acf migrate-hero-headline --dry-run=0 --batch=200Apply rename migrationMigrated: 37 Success: Updated schema version to 2026.02.0.
wp option get clinic_acf_schema_versionRead active schema version gate2026.02.0
wp clinic-acf schema-audit --write-baseline=1Persist Local JSON fingerprint baselinegroups=18 Success: Schema baseline saved.
wp clinic-acf schema-auditCompare runtime schema with baselineSuccess: Schema audit passed.
wp option get clinic_acf_schema_fingerprint --format=jsonInspect stored schema fingerprint object{"fingerprint":"ca1848e6c4a9fbf9d2dd25521269f8486639fc7675d675c891f2eb5b8b3f4b60","group_count":18,"generated_at":"2026-02-23T19:14:40+00:00"}
wp post meta get 991 _backup_case_summary_v1Read backup value for rollback safetyCandidate improved skin texture score by 37 percent in 8 weeks.
wp clinic-acf rollback-case-summary-v2 --post_id=991Restore one migrated recordSuccess: Rollback restored 1 posts.
ls wp-content/themes/clinic-headless/acf-json/Inspect Local JSON files before releasegroup_64f1a100ad31.json group_64f1a2a9f5f2.json group_64f1a5e4a762.json
grep -R '"key": "group_' wp-content/themes/clinic-headless/acf-json/*.jsonVerify field group keys present in JSONwp-content/themes/clinic-headless/acf-json/group_64f1a100ad31.json: "key": "group_64f1a100ad31"

Rollback Pattern

Use this forward and rollback sequence for migration releases. Run forward commands only after backup and dry run checks are green.

terminal: forward migration sequence
wp db export "./backups/pre-acf-v2026-02.sql"
wp clinic-acf schema-audit --write-baseline=1
wp clinic-acf migrate-hero-headline --dry-run=0 --batch=500
wp clinic-acf migrate-case-summary-v2 --dry-run=0
wp option update clinic_acf_schema_version "2026.02.0"
terminal: forward migration output
Success: Exported to './backups/pre-acf-v2026-02.sql'.
Success: Schema baseline saved.
Success: Updated schema version to 2026.02.0.
Success: Migration complete.
Success: Updated 'clinic_acf_schema_version' option.

If production verification fails, execute rollback immediately.

terminal: rollback sequence
wp clinic-acf rollback-case-summary-v2
wp option update clinic_acf_schema_version "2026.01.0"
wp eval 'echo get_option("clinic_acf_schema_version") . PHP_EOL;'
wp post meta get 991 _backup_case_summary_v1
terminal: rollback output
Success: Rollback restored 54 posts.
Success: Updated 'clinic_acf_schema_version' option.
2026.01.0
Candidate improved skin texture score by 37 percent in 8 weeks.

What's Next

tip

Revisit this lesson before any release that renames fields or changes field structures, because those are the moments where backup discipline and rollback readiness prevent prolonged incidents.