D
P
0

WordPress & PHP

The `get_theme_mod($id, $fallback)` Trap: The Fallback Arg Silently Overrides Your Registered Default

June 23, 2026·4 min read
The `get_theme_mod($id, $fallback)` Trap: The Fallback Arg Silently Overrides Your Registered Default

A custom theme I was building had an accent color you could set from the Customizer. The default was supposed to be dark, #0e0e0e. I registered the setting carefully, rendered the template, and what showed up on the page was clean white, #ffffff. No warning, no PHP error, nothing in the log. Just the wrong color, and a page that looked inverted from what I'd designed.

The trigger was one line in a page template:

$accent = get_theme_mod( 'accent_color', '#ffffff' );
echo '<div style="border-color:' . esc_attr( $accent ) . '">';

And the setting registration, which I assumed was my source of truth for the default:

$wp_customize->add_setting( 'accent_color', array(
    'default'           => '#0e0e0e',
    'sanitize_callback' => 'sanitize_hex_color',
) );

No value had ever been saved by a user for this setting. My reasoning: if nothing is saved, get_theme_mod should fall back to the default I registered, which is #0e0e0e. I treated the second argument '#ffffff' as an extra safety net that would never actually get used. To prove it, I even changed the registered 'default' to a different value — and the output stayed #ffffff. Changing the registered default had no effect at all. At that point I knew it was my assumption about precedence that was wrong, not the code itself.

Why this happens

get_theme_mod( $id, $default ) is not as simple as "read the saved value, and if it's empty use the registered default." The second argument is not a passive fallback. Internally, the returned value flows through a dynamic filter, theme_mod_{$id} — in my case theme_mod_accent_color — and that second argument is what gets used as the value passed into the filter when no saved value exists.

The result: when no user value is saved, the literal you pass as the second argument is used as-is. The '#ffffff' I thought was a safety net was actually the winner. It beat the 'default' => '#0e0e0e' I'd registered in add_setting.

This is the deceptive part: get_theme_mod's fallback argument is not "only used when truly unset in a harmless way." A non-empty literal passed as the second argument wins over your setting's default. The registered default only wins if the second argument is left empty so that it's the one flowing through the filter. As long as I was filling the second argument with a different non-empty value, the registration default would never be seen — exactly what I was experiencing. Those two "defaults" lived in two different places, and the one in the template call silently overrode the one in registration.

The fix

The quickest fix: pass an empty string as the second argument so there's no non-empty literal to override anything, and the registered default flows through.

$accent = get_theme_mod( 'accent_color', '' );

Or, if you really want to write a literal in the template, make sure that literal exactly matches the registered default:

// Must be identical to 'default' => '#0e0e0e' in add_setting().
$accent = get_theme_mod( 'accent_color', '#0e0e0e' );

Both of these work, but both are fragile. The first depends on the registration always being present and correct. The second keeps the same value in two places — change one and forget the other, and they start to silently differ, and you're right back in the same bug.

The solution I went with: a single defaults registry function used by both the Customizer registration and the template fallback, so the two can never drift.

function get_theme_defaults() {
    return array(
        'accent_color' => '#0e0e0e',
    );
}
 
// When registering the setting.
$defaults = get_theme_defaults();
$wp_customize->add_setting( 'accent_color', array(
    'default'           => $defaults['accent_color'],
    'sanitize_callback' => 'sanitize_hex_color',
) );
 
// In the template.
$defaults = get_theme_defaults();
$accent   = get_theme_mod( 'accent_color', $defaults['accent_color'] );

Now the literal passed to get_theme_mod is always equal to the registered default, because both read from the same source. Whether the second argument wins or the registration default wins, the value is identical, so nothing can go wrong anymore. Change #0e0e0e in one place, and both the registration and the template move together.

The takeaway

get_theme_mod's second argument is not an innocent safety net. A non-empty literal wins over the default you registered on the setting when no value has been saved, because it flows through the theme_mod_{$id} filter. The moment you have two places declaring a "default" — the setting registration and the template call — they will drift sooner or later, and the one that overrides is the one in the template. Keep a single source of truth for your defaults, shared by both the registration and the fallback, and a bug like this never gets a chance to appear.