Why Your Child Theme’s theme.json Overrides settings.spacing.units — and How to Read the Merged Value

The symptom is small and specific. A spacing control in the block editor offers a unit your parent theme’s theme.json never listed, or a unit the parent listed has quietly disappeared. The file on disk says one thing; the editor behaves as if it read another. Nothing in the browser console is red. Nothing in the debug log is complaining. The site is not broken, which is the annoying part.

This is not a bug in the merge. It is the merge working as designed, and the design is worth reading before you edit either file. The useful question is not “did the child theme override my units?” It almost certainly did. The useful question is which origin supplied the value I am reading, because once you know that, the fix is a one-line decision rather than a specificity escalation.

Trace: theme.json is a merge, not a file swap

WordPress does not read a single theme.json. It assembles settings from several origins and merges them in a fixed order. The theme.json specification is shared across those origins: core, themes, and users all use the same format, and themes override core defaults by shipping a theme.json, while users can override theme or core preferences through the site editor (Global Settings & Styles (theme.json)).

For a child theme, the resolver does something slightly more literal than “child wins.” When the active theme has a parent, core reads the parent’s theme.json, then merges the child’s theme.json into it. The source comment is explicit: “Merge the child theme.json into the parent theme.json. The child theme takes precedence over the parent” (WP_Theme_JSON_Resolver). So the child is not a replacement layer sitting beside the parent. It is an incoming layer applied on top.

That distinction matters because the merge rule for spacing.units is not additive. WP_Theme_JSON::merge() uses array_replace_recursive() for the general case, but then explicitly handles sequential arrays: “For leaf values that are sequential arrays it will use the numeric indexes for replacement. We rather replace the existing with the incoming value, if it exists. This is the case of spacing.units” (WP_Theme_JSON::merge). The same method then walks each setting node and, if the incoming data provides spacing.units, sets it wholesale:

// Replace the spacing.units.
$path = $node['path'];
$path[] = 'spacing';
$path[] = 'units';
$content = _wp_array_get( $incoming_data, $path, null );
if ( isset( $content ) ) {
    _wp_array_set( $this->theme_json, $path, $content );
}

So a child theme that declares settings.spacing.units is not appending to the parent’s list. It is replacing it. If the parent lists px, em, rem and the child lists rem only, the merged value is rem only. Reasoning about this as an additive override produces the wrong fix — usually adding the missing units in both files, which then produces a second, quieter disagreement later.

For reference, the documented default for settings.spacing.units is ["px","em","rem","vh","vw","%"], described as “List of units the user can use for spacing values” (Theme.json Version 3 Reference). If neither theme declares units, that default is what you are seeing. If one of them does, the incoming list wins for that node.

Inspect: read the merged value, not the file

The supported inspection tool is wp_get_global_settings(). Its own description is the whole argument: “Gets the settings resulting of merging core, theme, and user data” (wp_get_global_settings()). It takes a $path array and a $context array. The context accepts a block_name and an origin; the origin is either 'all' (core, theme, and user) or 'base' (core and theme), and defaults to 'all' when empty or unknown.

Start by dumping everything, then narrow. The function’s own user-contributed note recommends exactly this: call it with no path, var_dump the result, locate the setting, then convert the nested keys into a flat path array.

$settings = wp_get_global_settings();
var_dump( $settings['spacing']['units'] );

Then query the specific path directly:

$units = wp_get_global_settings( array( 'spacing', 'units' ) );
var_dump( $units );

That tells you what the merged value is. It does not tell you who supplied it. For that, query the same path twice with different origins:

// Core + theme only.
$base = wp_get_global_settings(
    array( 'spacing', 'units' ),
    array( 'origin' => 'base' )
);

// Core + theme + user.
$all = wp_get_global_settings(
    array( 'spacing', 'units' ),
    array( 'origin' => 'all' )
);

var_dump( $base, $all );

If $base and $all differ, a saved user layer is involved and editing either theme.json will not change what the editor offers. If they match, the value came from core plus the theme stack, and the child theme is the likely supplier. This two-query check is cheap and it prevents the most common wasted afternoon: rewriting a child theme.json while a saved global styles record quietly wins.

If you want to see the raw merged data rather than the settings slice, WP_Theme_JSON_Resolver::get_merged_data() is what wp_get_global_settings() calls internally. But the resolver class is documented as internal core API: “This class is for internal core usage and is not supposed to be used by extenders (plugins and/or themes). This is a low-level API that may need to do breaking changes. Please, use get_global_settings(), get_global_styles(), and get_global_stylesheet() instead” (WP_Theme_JSON_Resolver). Read it, do not ship a dependency on it.

Root cause: the cache is part of the merge story

There is a second reason a merged value can look wrong after a file edit, and it is not merge logic. wp_get_global_settings() caches its result in the theme_json cache group, and the source marks that group as non-persistent across requests. The cache key is built from the origin, and the function skips the cache entirely when development mode is set to theme:

$can_use_cached = ! wp_is_development_mode( 'theme' );

That line is in the function source on the reference page. The practical consequence: if you edit theme.json and re-read the merged value in the same request lifecycle without development mode on, you may be reading a cached array. The cache is non-persistent, so a fresh request usually clears it, but “usually” is not a diagnostic. If a value looks stale, confirm development mode before you conclude the merge is wrong.

There is also a filter layer above the file. The resolver applies wp_theme_json_data_default to core data and wp_theme_json_data_theme to theme data before merging (visible in the resolver source). A plugin that hooks either filter can change spacing.units without touching any file on disk. The sources establish that these filters exist; they do not establish that a filter is your cause. Treat it as a layer to rule out, not a default suspect.

Fix: decide which layer owns the list

Because spacing.units is replaced wholesale, the fix is an ownership decision, not a merge trick. Pick one layer to own the list and remove the declaration from the other.

  • Parent owns the units. Remove settings.spacing.units from the child theme.json. The parent’s list survives the merge because the child no longer supplies an incoming value for that node.
  • Child owns the units. Keep the list in the child and remove it from the parent, or accept that the parent’s list is documentation only. Do not maintain two lists; the second one is a future bug report.
  • Neither should own it. Remove the declaration from both and let the documented default apply.

What you should not do is add the missing units to both files and assume the union is what the editor sees. It is not. The incoming list replaces the existing one at that node, so the union exists only in your head.

If the two-query check showed that $base and $all differ, the fix is not in either theme.json. A saved user layer is supplying the value, and the correct action is to decide whether that saved customization should be kept, revised, or removed through the site editor’s styles workflow. That is an editorial decision about the site’s design, not a code change, and it should be made by whoever owns the design intent.

Verification: re-read after each change

After editing either file, re-run the same two queries. The verification is not “the editor looks right” — it is that the merged value at the path you care about changed to the value you intended, and that the base and all reads agree (or that the difference is the saved layer you deliberately kept).

// After the edit, in a request where development mode is 'theme'
// or after a fresh request:
$base = wp_get_global_settings(
    array( 'spacing', 'units' ),
    array( 'origin' => 'base' )
);
$all = wp_get_global_settings(
    array( 'spacing', 'units' ),
    array( 'origin' => 'all' )
);

// Expected: the list you decided on, and no unexplained divergence.
var_dump( $base, $all );

If the value is still wrong after a confirmed fresh read, the next layer to check is a filter on wp_theme_json_data_theme or wp_theme_json_data_default. Disable plugins that touch global styles one at a time and re-read. Do not escalate to !important in CSS; the unit selector is a settings-layer question, and CSS specificity has no opinion about it.

Why this failure mode is worth understanding

Most theme.json confusion comes from treating the file as a single source of truth. It is not. It is one input to a deterministic merge across origins, and the merge has explicit rules for which kinds of values are replaced and which are combined. spacing.units is in the replaced category, and the child theme is a layer applied on top of the parent, not a sibling. Once you read the merged value with wp_get_global_settings() and separate base from all, the question stops being “why is WordPress ignoring my file” and becomes “which layer did I actually ask to own this list.” That is a question with an answer.

FAQ

Does a child theme’s theme.json replace the parent’s entire file?

No. Core reads the parent theme.json and merges the child’s into it, with the child taking precedence. The replacement behavior is per-node: for sequential arrays like spacing.units, the incoming list replaces the existing one at that node rather than being concatenated.

Why does the editor offer a unit my parent theme.json does not list?

Either the child theme supplies a different spacing.units list, or a saved user layer supplies one, or neither theme declares units and the documented default applies. Query wp_get_global_settings( array( 'spacing', 'units' ) ) with origin 'base' and 'all' to tell those cases apart.

Can I just add the missing units to both files?

You can, but the merged value will still be whichever list arrives last at that node, not the union. Pick one layer to own the list and remove the declaration from the other.

Why did my edit not change the merged value?

Check two things before suspecting the merge: whether development mode is set to theme (which bypasses the theme_json cache), and whether a plugin is filtering wp_theme_json_data_theme or wp_theme_json_data_default. Both can produce a value that does not match the file you just saved.

Should I call WP_Theme_JSON_Resolver directly to debug?

Read it if you need to understand the merge, but do not ship code that depends on it. The class is documented as internal core API subject to breaking changes, with get_global_settings(), get_global_styles(), and get_global_stylesheet() named as the supported alternatives.