D
P
0

WordPress & PHP

`update_option` Migration Succeeded but the Frontend Still Shows the Old Value? One Setting Stored Under Two Option Keys

June 29, 2026·4 min read
`update_option` Migration Succeeded but the Frontend Still Shows the Old Value? One Setting Stored Under Two Option Keys

The request was trivial: change the contact email shown in the footer and on the contact page of a client site. The value lived as an option in the database, so instead of clicking around the admin, I wrote a small migration so the change would be recorded and repeatable:

$settings = get_option( 'theme_settings', array() );
$settings['contact_email'] = $new_email;
update_option( 'theme_settings', $settings );

The migration ran, no errors, update_option returned true. I opened the settings page in the admin: the new email sat neatly in its field. Done, I thought. Then I checked the public page, and the old email was still squatting in the footer. I purged the cache, hard refreshed, opened incognito, checked from my phone. Still the old email.

This is the most maddening class of bug: the admin says X, the frontend says Y, and both are convinced they are right. The migration "succeeded", the admin proved it, yet visitors were seeing a different world. Split-brain.

The investigation: where did the new value actually go

My first reflex was to blame caching, and that cost me half an hour. Once the cache was provably clean, I stopped guessing and interrogated the database directly with WP-CLI:

wp option pluck theme_settings contact_email
# new-team@example.com
 
wp option get site_contact_email
# old-team@example.com

And there it was, naked in the terminal. The same value was living under TWO different option keys. My migration had changed theme_settings[contact_email], and it genuinely had changed. But a second key, site_contact_email, was still holding the old value. One question remained: who was reading the old key?

grep -rn "site_contact_email" wp-content/themes/
grep -rn "get_option( 'theme_settings'" wp-content/themes/

The grep output explained everything. The footer and contact page templates, the oldest code in the theme, read site_contact_email directly via get_option. The admin settings page, a newer refactor, read from and wrote to the theme_settings array. The admin showed the new value because it read the key I had patched. The frontend showed the old value because it read the key I had never touched.

The root cause: historical drift

Nothing malicious here, just history. An early version of the theme saved the email to site_contact_email. Years later, a refactor introduced the tidier theme_settings array, complete with a new settings page. But the refactor stopped at the admin: the frontend templates were never migrated over, and nobody deleted the old path. From that day on, one value had two sources of truth, and they happened to stay in sync... until my migration touched only one of them.

The root cause was not that my migration was written wrong. It was correct for the key it targeted. The root cause was architectural: one value, two storage locations, zero single accessor. As long as that condition stands, every future change is a coin flip: you either hit the right key or you do not.

The fix

The short-term fix: the migration has to write to BOTH keys, so that whoever the reader is, the value is consistent:

function migrate_contact_email( $new_email ) {
    // canonical key
    $settings = get_option( 'theme_settings', array() );
    $settings['contact_email'] = $new_email;
    update_option( 'theme_settings', $settings );
 
    // legacy key: keep in sync until every reader is refactored
    update_option( 'site_contact_email', $new_email );
}

But that only patches the symptom. The real fix is to eliminate the two-sources-of-truth condition itself. I introduced a single getter that became the only door for reading this value, with the new key as canonical and the legacy key as fallback:

function get_contact_email() {
    $settings = get_option( 'theme_settings', array() );
 
    if ( ! empty( $settings['contact_email'] ) ) {
        return $settings['contact_email'];
    }
 
    // fallback for values that only ever lived in the legacy key
    return get_option( 'site_contact_email', '' );
}

Then I refactored every call site, footer, contact page, all of it, to go through get_contact_email(). No more naked get_option calls for this value anywhere in the templates. The endgame: after one release cycle runs clean, the legacy site_contact_email key gets deleted outright, and the fallback goes with it.

The takeaway

The checklist I carried home from this incident:

First, before patching a value, grep for EVERY place that reads it. Hunt down every relevant get_option call; never assume there is only one key.

Second, when the admin shows X but the frontend shows Y, and the cache is provably clean, suspect two storage keys. It is the only way both sides can be simultaneously "right" while contradicting each other.

Third, one value = one accessor function. The moment the same value has two storage locations, the only sane way out is a single canonical getter with a fallback, refactoring every reader through it, and then killing the old key. A migration that "succeeded" means nothing if it succeeded at the wrong address.