← Blog

WordPress

Headless WordPress migration: a 13-day ACF Pro stall

A 31-person logistics vendor in Mechelen sat thirteen days behind on a headless WordPress launch. The blocker was four bytes: a:0:{} returned by WPGraphQL on 8,600 posts.

Jacob Molkenboer· Founder · A Brand New Company· 14 Jun 2026· 8 min
Open weathered leather logbook with green ribbon, brass tag, rubber stamp, cracked red wax seal on ivory paper.

Day thirteen at the Mechelen office

At 23:18 on a Friday in early June, the lead engineer at a 31-person logistics-software vendor in Mechelen pushed a single message into our project channel:

"It's still a:0:{} on every post. Every single one. I'm going home."

The launch had been moved twice. The new site was meant to ship on a Tuesday. We were thirteen calendar days late, and the entire blocker was four bytes.

This post is the autopsy of those thirteen days. If you run a WordPress site that has been around for more than five years and you are thinking about going headless, read it before you start.

The headless stack

The client had run a brochure-plus-case-studies WordPress 6.4 site since 2017. Page builder layered on page builder, then ACF Pro retro-fitted over the top so the marketing team could publish ten case studies a month without filing a ticket. The engineering team did not touch WordPress except when it broke.

The brief for the rebuild was unremarkable for 2026. WordPress stays as the editor and the content store. Next.js renders the public site. WPGraphQL feeds Next.js at build time, with on-demand revalidation for the rest. Fly.io for the front-end, Hetzner for the WordPress origin behind Cloudflare.

There were 8,604 case-study posts in the database. Each one used a single ACF Pro flexible-content field called modules, with seven possible layouts: hero, quote, two-column, gallery, KPI grid, video embed, CTA. Around 84% of the case studies used four or more module instances. The flexible-content field was the site.

The first WPGraphQL response

The first query against staging was a single case study by slug, asking for the modules flexible-content union:

query {
  caseStudy(id: "fonteyne-returns-2024", idType: SLUG) {
    title
    modules {
      ... on CaseStudyModulesHeroLayout { heading subheading }
      ... on CaseStudyModulesQuoteLayout { quote attribution }
    }
  }
}

The response came back with modules: null and no GraphQL error. Schema mismatch, we assumed. We probed the raw metaData field on the post directly. There it was, in the JSON response:

{ "key": "modules", "value": "a:0:{}" }

Eight thousand six hundred and four posts. All a:0:{}. Every flexible-content field returned the same literal string. The CMS admin rendered the modules correctly. The front-end could not see any of them.

What we ruled out

Day one to day six was rabbit-hole work. For the record, here is what was not the problem.

WPGraphQL version. We pinned to 1.27, then tried 1.26 and the 2.0 beta. Same response.

WPGraphQL for ACF version. The plugin was rewritten in 2023, and we initially blamed the rewrite. It was not the rewrite.

ACF Pro version. We tested 6.2.10 against 6.3.4. Same response.

The field-group registration path. We tried both PHP-registered and JSON-sync registered groups. Same response.

A Cloudflare cache layer in front of the origin. We bypassed it. Same response. A WordPress object cache. We flushed it, then disabled it. Same response.

By day six the front-end team had stopped working on case-study templates and was building everything else. By day nine the marketing director asked, with admirable restraint, whether we knew what was wrong. We did not. So we went to the database.

ACF's double-serialization trap

ACF Pro stores a flexible-content field's layout list as a serialized PHP array in wp_postmeta. For our modules field, a healthy row looks like this:

meta_key:   modules
meta_value: a:3:{i:0;s:4:"hero";i:1;s:5:"quote";i:2;s:7:"two_col";}

WordPress's storage layer calls maybe_serialize() on write and maybe_unserialize() on read. When get_field() runs, the value comes back as a real PHP array. WPGraphQL for ACF then maps each layout to a GraphQL union member. That is the happy path. The ACF flexible-content documentation describes the layout contract, and the WPGraphQL for ACF documentation describes how that contract is mapped to GraphQL unions.

What we found in production was different. The row looked like this:

meta_key:   modules
meta_value: s:8:"a:0:{}";

A string. Containing the literal text a:0:{}. Serialized.

When maybe_unserialize() ran on that value, it returned the inner string a:0:{}. It did not run a second pass. It is not supposed to. ACF's resolver received a string where it expected an array, treated it as an empty layout list, and the front-end union returned nothing. The four bytes were not the cause. They were the echo.

The cause was a migration script written in 2019, when the site moved hosts. The script had pulled wp_postmeta out of the old database, run a serialize() over the values to "make them safe for transport", and reinserted them. Every flexible-content field that was empty at the time of that move had become a serialized representation of a serialized empty array. New posts created after 2019 were fine. Posts that had been edited since 2019 were fine, because saving a post in the admin re-serialized the meta value through the proper path. The 8,517 case studies that had been published, then left alone for years, were all double-serialized.

Warning

If your WordPress site has been migrated between hosts more than once, query wp_postmeta for values whose stored string is itself a serialized string. WordPress functions will not catch this. WPGraphQL will not catch this. The admin UI silently fixes it the moment a human edits the post, which is why nobody notices for years.

The cleanup script

The fix was a one-shot script run inside wp-cli. We did three things. Audit every row. Dry-run the un-wrap and log the result. Write the cleaned value back inside a transaction with a rollback path.

The core of the cleanup, with the dry-run flag removed for brevity:

<?php
// wp-cli eval-file fix-acf-double-serialize.php

global $wpdb;

$flex_keys = [ 'modules', 'page_blocks', 'kpi_grid_items' ];

foreach ( $flex_keys as $key ) {
    $rows = $wpdb->get_results( $wpdb->prepare(
        "SELECT meta_id, post_id, meta_value
         FROM {$wpdb->postmeta}
         WHERE meta_key = %s
         AND meta_value LIKE 's:%%'",
        $key
    ) );

    foreach ( $rows as $row ) {
        $first = @unserialize( $row->meta_value );
        if ( ! is_string( $first ) ) { continue; }

        $second = @unserialize( $first );
        if ( ! is_array( $second ) ) { continue; }

        $wpdb->update(
            $wpdb->postmeta,
            [ 'meta_value' => maybe_serialize( $second ) ],
            [ 'meta_id'    => $row->meta_id ]
        );

        WP_CLI::log( "Fixed post {$row->post_id} key {$key}" );
    }
}

We ran it against a clone of production first. The audit found 8,517 of the 8,604 case studies affected, plus 3,212 posts across two other content types that used flexible-content fields the marketing team had forgotten about. The fix completed in 14 minutes. We diffed the GraphQL output before and after on a sample of 200 posts. Every layout came back.

We ran the same script against production during a Sunday maintenance window. The launch shipped Monday.

Auditing before the rebuild

Most of those thirteen days were spent ruling out the wrong things. The hour of real pain was a database query. The lesson is that you should run that query on day zero, before you write a single line of Next.js.

Three checks we now run on every WordPress-to-headless engagement.

A raw count of wp_postmeta rows whose meta_value stores a serialized string fragment instead of an array or scalar. If the count is non-zero, you have a migration scar somewhere, and you need to know where before you build a resolver on top of it.

A WPGraphQL probe of the ACF fields you actually care about, against at least fifty randomly sampled posts across the full age range of the site. Sample the oldest 10%, the middle 80%, and the newest 10%. The bug in this story was invisible on the newest 10% because those posts were created after the bad migration, which is exactly how it survived seven years of casual QA.

A diff of the field-group JSON between the live site and the staging site. ACF JSON sync is reliable until somebody edits a field group through the admin in production and forgets to commit the JSON file. Both versions of the field group can be technically correct and still resolve to different layouts on different environments.

None of this is interesting work. All of it would have saved us thirteen days.

One thing to check on Monday

When we rebuilt the headless front-end for the Mechelen vendor, the field that nearly killed the launch had silently been a double-serialized empty array since the 2019 host move, and we caught it on day thirteen instead of day zero. Most of the legacy migration work we do at ABN now starts with a thirty-minute database audit before any architecture conversation, because the cost of catching a scar like this on day zero is a SQL query and the cost of catching it on day thirteen is a launch.

Open wp-cli on a clone of your production database tonight and run one query:

SELECT COUNT(*) FROM wp_postmeta
WHERE meta_value LIKE 's:%a:0:{}%';

If the answer is not zero, you have the same scar we did. Better to find it on a Monday than at 23:18 on the second Friday of the rebuild.

Key takeaway

Before you go headless on a legacy WordPress site, query wp_postmeta for double-serialized values. The admin UI hides the problem; WPGraphQL will not.

FAQ

What does the literal string 'a:0:{}' actually mean?

It is the PHP serialized representation of an empty array. WPGraphQL returned it as a string because the underlying meta value had been serialized twice, so one unserialize pass left a string behind.

Will the WordPress admin UI show this problem?

No. Editing and saving a post once re-serializes the value correctly through the proper code path. The scar only stays on posts that have not been edited since the bad migration ran.

Can I run the cleanup script directly against a live site?

Yes, but mirror the database first and run the cleanup against the clone. Compare the audit count before and after. Only write back to production once the dry-run numbers match what you expected.

Does this still happen with the latest ACF Pro and WPGraphQL for ACF?

Yes. The bug lives in the stored data, not in the plugins. Both still call unserialize once and trust the result. If your meta is double-serialized, no plugin update will rescue you.

How do I tell whether a meta value is double-serialized?

A serialized string starts with 's:' followed by a length. If unserializing it returns another string that itself starts with 'a:', 'O:' or 's:', you are looking at a double-serialized value that needs a second pass.

wordpressmigrationphpmysqllegacy sitesarchitecture

Building something?

Start a project