Migration, Versioning, and Rollback Playbooks
Reliable ACF migrations are release workflows, not one-time scripts.
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.
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.
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
| Approach | What Happens | Impact in Production |
|---|---|---|
| Version-gated idempotent migrations | Commands can be retried safely after interruptions | Lower release risk and cleaner incident recovery |
| Save backup meta before writing new values | Rollback can restore exact pre-migration content | Faster mitigation during failed releases |
| Store touched post IDs in option log | Teams know exactly what changed and where | Targeted audits instead of broad manual review |
| Compare schema fingerprint before and after release | Unexpected field-group drift is caught early | Better configuration integrity across environments |
| Run direct destructive migration with no dry run (wrong pattern) | In-place overwrite may lose legacy values | Data loss risk and long recovery windows |
Reference Table
| Term/API | Signature/Syntax | Purpose | Key Notes |
|---|---|---|---|
acf/settings/save_json | add_filter('acf/settings/save_json', callable $callback, int $priority = 10, int $accepted_args = 1): string | Set canonical Local JSON export path | Keep path consistent across team environments |
acf/settings/load_json | add_filter('acf/settings/load_json', callable $callback, int $priority = 10, int $accepted_args = 1): array | Add one or more Local JSON import paths | Supports shared package or release-candidate paths |
acf_get_field_groups() | acf_get_field_groups(array $filter = []): array | Read runtime-registered field groups | Useful for schema baseline and drift checks |
get_field() | `get_field(string $selector, int | string $post_id = false, bool $format_value = true): mixed` | Read source value during migration |
update_field() | `update_field(string $selector, mixed $value, int | string $post_id = false): int | bool` |
WP_Query ID batch | new WP_Query(['post_type' => 'page', 'fields' => 'ids', 'posts_per_page' => 200, 'no_found_rows' => true]) | Scan large content sets efficiently | Reduces memory and query overhead |
update_option() | `update_option(string $option, mixed $value, bool | string $autoload = null): bool` | Persist schema version and migration logs |
wp eval-file | wp eval-file <path> | Run migration script from CLI | Good for controlled one-off operations |
wp db export | wp db export <file> | Create pre-migration backup snapshot | Mandatory for high-risk transformations |
wp db import | wp db import <file> | Restore previous DB state in full rollback | Use only with approved incident playbook |
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.
- Register a WP-CLI command from
acf/initso it runs only when ACF is available. - Query page IDs in predictable batches.
- Copy value only when old field is non-empty and new field is empty.
- Save backup value before write and store touched IDs for audit.
- Update
clinic_acf_schema_versionafter successful live run.
<?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.');
});
});
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
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
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.
- Set explicit save and load paths for Local JSON.
- Register a CLI command that calculates a deterministic fingerprint from JSON files.
- Compare current fingerprint and group count against saved baseline.
- Optionally write baseline during release preparation.
- Block migration steps when mismatch is detected.
<?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.');
});
});
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/
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
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)
<?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)
<?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.');
});
});
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;'
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.'
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
| Mistake | Root Cause | What Breaks in Production | Correct Pattern |
|---|---|---|---|
| Live migration executed before dry run review | Delivery pressure and no checklist gate | Unexpected write volume and silent bad mappings | wp clinic-acf migrate-hero-headline --dry-run=1 --batch=200 |
| No per-record backup before target write | Assumes migration will always succeed | Rollback cannot restore original values | update_post_meta($postId, '_backup_case_summary_v1', $legacy); |
| Schema version not persisted | No state gate for re-runs | Same migration may run repeatedly on deploy | wp option get clinic_acf_schema_version |
| Local JSON drift ignored | Runtime field groups differ from repo | Migration targets wrong field keys or names | wp clinic-acf schema-audit |
| Rollback path untested in staging | Team focuses only on forward success | Incident response time increases sharply | wp clinic-acf rollback-case-summary-v2 --post_id=991 |
| Migration logs not retained | Touched IDs never recorded | Audits and selective fixes become manual | wp 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
- Keep one release schema option and verify each deploy with
wp option get clinic_acf_schema_version. - Require dry run output in deployment notes:
wp clinic-acf migrate-case-summary-v2 --dry-run=1. - Save touched IDs for every migration using
update_option('clinic_acf_migration_<id>_ids', $ids, false);. - Validate Local JSON fingerprint before running data writes:
wp clinic-acf schema-audit. - Back up old field value before each write using
update_post_meta($postId, '_backup_<field>', $oldValue);. - Keep rollback command executable per record and batch mode in the same release artifact.
- 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
| Command | Purpose | Real Example Output |
|---|---|---|
wp clinic-acf migrate-hero-headline --dry-run=1 --batch=200 | Preview rename migration effect | Scanned: 200 Would migrate: 37 Skipped: 163 |
wp clinic-acf migrate-hero-headline --dry-run=0 --batch=200 | Apply rename migration | Migrated: 37 Success: Updated schema version to 2026.02.0. |
wp option get clinic_acf_schema_version | Read active schema version gate | 2026.02.0 |
wp clinic-acf schema-audit --write-baseline=1 | Persist Local JSON fingerprint baseline | groups=18 Success: Schema baseline saved. |
wp clinic-acf schema-audit | Compare runtime schema with baseline | Success: Schema audit passed. |
wp option get clinic_acf_schema_fingerprint --format=json | Inspect 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_v1 | Read backup value for rollback safety | Candidate improved skin texture score by 37 percent in 8 weeks. |
wp clinic-acf rollback-case-summary-v2 --post_id=991 | Restore one migrated record | Success: Rollback restored 1 posts. |
ls wp-content/themes/clinic-headless/acf-json/ | Inspect Local JSON files before release | group_64f1a100ad31.json group_64f1a2a9f5f2.json group_64f1a5e4a762.json |
grep -R '"key": "group_' wp-content/themes/clinic-headless/acf-json/*.json | Verify field group keys present in JSON | wp-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.
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"
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.
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
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
- Continue to Module 9 Lesson 1: ACF Test Strategy, Fixtures, and Scenario Matrix.
- Return to Module 8 Overview to connect migration operations with your headless delivery standards.
- Related lesson: CI/CD Pipeline Quality Gates and Release Checks.
- Related lesson: Local JSON and Version Control Workflow.
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.