Aditya Sharma

Theme.json: the parts that actually control your output

On this page, 9 sections

Two theme.json files, identical except that one has a version key. Here is what WordPress 7.1 does with each, run through WP_Theme_JSON_Schema::migrate() on a live install:

WITH version:    {"version":3,"settings":{"color":{"palette":[{"slug":"brand","color":"#ee6719","name":"Brand"}]}}}
WITHOUT version: {"version":3}

Not a warning. Not a partial merge. The entire file is replaced by a stub. Here is the code that does it, in wp-includes/class-wp-theme-json-schema.php:

public static function migrate( $theme_json, $origin = 'theme' ) {
	if ( ! isset( $theme_json['version'] ) ) {
		$theme_json = array(
			'version' => WP_Theme_JSON::LATEST_SCHEMA,
		);
	}
	...
}

That is called from the WP_Theme_JSON constructor, so it runs on every layer, every request. If your theme.json is being ignored and you cannot see why, check that key first.

It is the cheapest possible explanation and it is a one-line fix.

This post is about the parts of theme.json that decide what actually reaches the browser: the four layers and their order, what settings and styles each do, the specificity of the CSS core generates, and the three ways your changes get silently dropped.

Everything is read from WordPress 7.1 source and checked on a live install, on 2 September 2026.

Four layers, merged in a fixed order

There is no ambiguity here and it is worth memorising, because most theme.json confusion is a layer confusion. WP_Theme_JSON_Resolver::get_merged_data() is short enough to read whole: The resolver builds it in one function.

It constructs an empty WP_Theme_JSON, merges get_core_data(), and returns early if the requested origin is default. Then it merges get_block_data(), then get_theme_data(), then get_user_data(), returning early at each named origin.

LayerOrigin keyWhere it comes fromBeats
Coredefaultwp-includes/theme.jsonnothing
Blocksblockseach block type’s __experimentalStyle supportcore
Themethemeyour theme’s theme.json, plus the parent theme’score and blocks
Usercustoma wp_global_styles post, written by the Site Editoreverything

The constant that defines the order

The constant is WP_Theme_JSON::VALID_ORIGINS and the order in it is the merge order. LATEST_SCHEMA is 3. Note that the user layer is called custom in the class and user in the resolver’s cache array, which is a small trap when you are grepping.

One filter per layer

Each layer has a filter: wp_theme_json_data_default, wp_theme_json_data_blocks, wp_theme_json_data_theme and wp_theme_json_data_user. A plugin can rewrite any of them. If a value in your output does not appear in any theme.json on disk, one of those four filters is where it came from.

settings generate variables and gate UI. styles generate rules.

What settings does, and what styles does

The split is real and people mix it up constantly. Anything under settings does two things: it emits CSS custom properties, and it turns editor controls on or off. Anything under styles emits actual declarations.

On a real Twenty Twenty-Five page in WordPress 7.1 the settings half is a :root block of custom properties: --wp--preset--color--base: #FFFFFF, --wp--preset--color--contrast: #111111, --wp--preset--color--accent-1: #FFEE58, --wp--preset--aspect-ratio--16-9: 16/9, and so on for every preset the theme declares.

And the styles half emits one bare body rule that consumes them: background-color, color, font-family, font-size, font-weight, letter-spacing and line-height, plus the root padding variables.

How big the inline block actually is

The whole global-styles-inline-css block on that page is 20,528 bytes, and it is inline rather than a file, so it is on every page and it is not cacheable independently of the HTML.

Inside it: 49 rules using the :root :where( prefix, 87 !important declarations, exactly one bare body{ rule and one :root{ rule. Those counts came off that one page with that one theme.

The shape is what generalises, and the shape is what the rest of this section is about.

The gating half nobody expects

20,528 bytes of inline global styles CSS on one page
Measured on WordPress 7.1 with Twenty Twenty-Five, 2 September 2026.

The gating half of settings is the part people discover by accident. Set settings.color.custom to false and the custom colour picker disappears from the editor. Set settings.typography.customFontSize to false and the numeric font size input goes.

That is the same class of problem as missing colour and underline options in the WordPress editor, which usually turns out to be a theme.json setting rather than a bug.

The specificity rules core actually uses

This is the piece that explains why your stylesheet sometimes wins and sometimes does not.

Core does not emit one selector shape, it emits three, and the choice is made in WP_Theme_JSON::get_styles_for_block(): The branch is one assignment: $general_selector = $element_only_selector ? $selector : ":root :where($selector)". An element-only selector stays bare. Everything else gets wrapped in :root :where(...).

What core is stylingSelector emittedSpecificity
The root (ROOT_BLOCK_SELECTOR is body)body0-0-1
An element with an element-only selector, such as h1h10-0-1
Everything else: blocks, pseudo states, variations:root :where(.wp-block-x)0-1-0
Preset classes such as .has-accent-1-colorclass plus !important0-1-0 plus important

Why the root selector is left bare

The comment above that code says why the root is left bare: to keep specificity at 0-0-1 for backwards compatibility.

:where() contributes nothing, so :root :where(...) is exactly the specificity of :root alone, which is one class.

That is the design goal, stated in the source: cap everything block-related at a single class so a theme’s own stylesheet can override it without an arms race.

It also means your override needs one class and later source order, or two classes. A bare element selector will lose to :root :where(). This is the mechanism behind most of the stubborn spacing you cannot remove on block themes.

The preset classes carry !important

The preset classes are the exception and they are brutal. compute_preset_classes() appends a ruleset whose value is 'var(' . $css_var . ') !important'. The !important is written into the generated CSS by core, not by your theme.

So .has-accent-1-color { color: var(--wp--preset--color--accent-1) !important; }. If a user has picked a palette colour on a block, you are not overriding it from a stylesheet without your own !important. That is deliberate: the user’s explicit choice is meant to win.

What changed in 7.1

One thing changed in 7.1 that is worth knowing if you support older versions.

The docblock on compute_preset_classes() now reads @since 7.1.0 Wraps block-level preset classes in :where() to match root-level specificity, and the code comment explains that without it, block-level palette rules such as p.has-x-color outranked equally important rules targeting the same property at 0-1-0.

If a palette colour started behaving differently after a 7.1 update, that is the change.

The user layer, and how it silently disappears

The Site Editor writes user global styles into a post of type wp_global_styles. That post is tied to a theme by a term in the wp_theme taxonomy, and if the term is missing the post is orphaned and its styles never apply.

The WordPress Site Editor Styles panel showing the typography, colours, background, shadows and layout sections and a revision count.
The Site Editor Styles panel on WordPress 7.2-alpha-63436 under wp-env, showing the user layer after a global styles write. Screenshot taken 2 September 2026.

The term that was never set

I hit this while testing. I wrote a background and text colour into the global styles post, confirmed the content was saved, flushed the object cache, and the front end still showed the theme’s colours.

The Site Editor writes through /wp-json/wp/v2/global-styles/<id> and behaves the same way: The probe printed post id 6, status publish, type wp_global_styles, content carrying my #0b0e13 background and #edeff3 text, stylesheet twentytwentyfive, and then the two fields that mattered: terms= empty, and user raw={"version":3}.

terms= empty. The post existed with the right content, and WP_Theme_JSON_Resolver::get_user_data() returned nothing but a version number, because get_user_data_from_wp_global_styles() looks the post up through the wp_theme term for the current stylesheet.

Setting the term fixed it immediately. wp eval 'wp_set_post_terms( 6, array( "twentytwentyfive" ), "wp_theme" );' followed by wp cache flush, and the body rule went from background-color: var(--wp--preset--color--base) to the literal #0b0e13 I had saved.

Why a theme switch appears to reset your styles

That is also why switching themes appears to reset your Site Editor customisations and switching back restores them. Each theme has its own wp_global_styles post, keyed by that term. Nothing is lost, it is scoped.

The same scoping is why a child theme gets its own user styles rather than inheriting the parent’s, which is a different answer from the one you get for template files and a common source of confusion when deciding whether a child theme is the right tool.

Version migration, and what v3 changed

migrate() falls through deliberately, so a v1 file goes to v2 and then straight on to v3 in the same call. The v1 to v2 step renames four settings paths: border.customRadius to border.radius, spacing.customMargin to spacing.margin, spacing.customPadding to spacing.padding, typography.customLineHeight to typography.lineHeight.

What v2 to v3 actually changes

The v2 to v3 step is the one that changes behaviour rather than names:

  • If your v2 file defines settings.typography.fontSizes, migration sets defaultFontSizes to false. In v2, providing font sizes replaced core’s. In v3 they are added to core’s unless you opt out, so a v3 file that only lists three sizes gets core’s defaults alongside them.
  • The same applies to settings.spacing.spacingSizes and spacingScale, which set defaultSpacingSizes to false on migration.
  • In v3, spacingSizes merges with the generated spacingScale sizes instead of replacing them. The source comment is unusually candid about this: it says the v3 behaviour is what was documented for v2 all along and the code never did it, so rather than change v2 behaviour two years later they treat the fix as a v3 breaking change. Migration therefore unsets spacingScale for v2 files to preserve the old bug.

The practical consequence when you bump a theme from v2 to v3 by hand: add defaultFontSizes: false and defaultSpacingSizes: false yourself if you were relying on your presets replacing core’s, because the automatic migration that was doing that for you stops running the moment you write "version": 3.

The theme.json Version 3 Reference page in the WordPress block editor handbook, showing the settings and styles trees.
developer.wordpress.org/block-editor/reference-guides/theme-json-reference/theme-json-living/, screenshot taken 2 September 2026.

How to debug it when it does not apply

In order, cheapest first. Every one of these is a real cause I hit or read in source today.

  1. No version key. The whole file is discarded and replaced with {"version":3}. Add "version": 3 and the $schema line while you are there.
  2. The stylesheet is cached. wp_get_global_stylesheet() caches its output and only skips the cache when wp_is_development_mode( 'theme' ) is true. Put define( 'WP_DEVELOPMENT_MODE', 'theme' ); in wp-config.php while you work, and take it out afterwards.
  3. A user global styles post is overriding you. It is the last layer merged and it always wins. wp post list --post_type=wp_global_styles, read the content, and remember it is scoped to the theme by a wp_theme term.
  4. Your selector is losing to :root :where(). That is 0-1-0. One element selector will not beat it. Two classes, or one class later in source order, will.
  5. It is a preset class with !important. Nothing without !important overrides those. Change the preset value in theme.json rather than fighting the class.
  6. A plugin filtered a layer. Grep your plugins for wp_theme_json_data_theme and its three siblings.
  7. The block does not support that style. The blocks layer merges block-level defaults, and a block that does not declare a support will not produce the declaration however you write it.

Two functions to keep in your fingers

Two functions worth having in your fingers while debugging: wp_get_global_settings( array( 'color', 'palette' ) ) and wp_get_global_styles().

Both take a path array and both give you the merged result rather than any single file, which is the value you are actually trying to reason about.

What I would not claim

The 20,528 byte figure for global styles is one page, one theme, one version. Twenty Twenty-Five is a large theme.json and a minimal one will produce far less.

I have not benchmarked global styles output across a set of themes and I am not going to imply a range from a single sample.

What is version-independent is the mechanism: the four layers, the merge order, the three selector shapes and the !important on preset classes. Those are in the source and they are what you can reason from.

One thing to do in the next ten minutes

Open your theme’s theme.json and check the top three lines. You want a $schema line and a version line, in that order:

{
	"$schema": "https://schemas.wp.org/wp/6.7/theme.json",
	"version": 3,
	"settings": { ... }
}
The JSON schema for WordPress block theme global settings and styles at schemas.wp.org
schemas.wp.org/trunk/theme.json, the schema the $schema line points at. Screenshot taken 3 September 2026.

Twenty Twenty-Five ships exactly that, pinned to the 6.7 schema rather than trunk, which is the right call: pin to the lowest WordPress version you support so your editor validates against what your users are running.

If the version line is missing, everything below it in that file has been doing nothing, and you are about to get a very satisfying ten minutes.

For where theme.json sits in the wider set of changes this cycle, I wrote that up in WordPress 7.0 is the biggest thing to happen since Gutenberg.

If you are working on the block side rather than the theme side, the block.json asset loading piece covers the other half of what ends up in your head tag.

Resources

Tell me where I am wrong

Your email is not published and I do not add it to any list. Corrections with a source are the ones I act on fastest.