Why Your Block Deprecation Silently Fails: How the Editor Validates save() Output Against block.json Attributes Before It Ever Migrates Old Markup

You changed the wrapper element in save(). You added a deprecated entry with a migrate() that renames the attribute. You opened an old post. The block still shows the invalid-content warning, and your migrate() never runs. You add a console.log inside it. Nothing. You conclude the deprecation API is broken.

It is not broken. It is gated. The editor does not run your migration because your deprecation’s save() did not reproduce the stored markup, and a deprecation whose save() fails validation is skipped entirely — including its migrate(). The migration logic is downstream of a validation check that most people debug in the wrong order.

Symptom

Old block instances show the invalid-content warning after a save() or attribute change. The deprecated array is populated. The migrate() function is correct in isolation. It does not execute.

If you instrument the deprecation object, you will find that isEligible may run, but migrate does not. That asymmetry is the tell: the entry was considered, its save() was evaluated, and the result did not validate. The entry was discarded before the migration stage.

Trace

The selection loop is documented in the Block Deprecation reference. The relevant sequence, quoted from the WordPress Block Editor handbook:

If the current save method does not produce a valid block the first deprecation in the deprecations array is passed the original saved content. If its save method produces valid content this deprecation is used to parse the block attributes. If it has a migrate method it will also be run using the attributes parsed by the deprecation. If the first deprecation’s save method does not produce a valid block the subsequent deprecations in the array are tried until one producing a valid block is encountered.

And the gate that produces the silent skip:

It is important to note that if a deprecation’s save method does not produce a valid block then it is skipped completely, including its migrate method, even if isEligible would return true for the given attributes.

Two things follow from that. First, the deprecation array is not a migration chain. The documentation is explicit: “Deprecations do not operate as a chain of updates in the way other software data updates, like database migrations, do.” Each entry is evaluated against the original saved content, not against the output of the previous entry. Second, the entry that validates is the only one whose attributes are passed forward. The attributes and inner blocks from the first deprecation that produces valid content are passed back to the current save() to generate new content. Earlier entries are not fed forward.

So the loop is a search, not a pipeline. It looks like this:

stored markup
  │
  ├─ current save() validates? ──> use current block, stop
  │
  └─ invalid
       │
       ├─ deprecation[0].save() validates? ──> parse attrs with deprecation[0].attributes
       │                                        run deprecation[0].migrate() if present
       │                                        pass attrs + innerBlocks to current save()
       │                                        stop
       │
       ├─ deprecation[1].save() validates? ──> same, with deprecation[1]
       │
       └─ ...

If no entry validates, the block remains invalid. The editor does not fall through to the current save() with guessed attributes. It does not run any migrate(). It does not warn you that your save() was the problem.

Root cause

The validation check is not comparing your deprecation’s save() output to the current block’s save() output. It is comparing the serialized result of the deprecation’s save() — using the deprecation’s own attribute definitions — to the original stored markup. The @wordpress/blocks reference states the validity condition plainly: “A block is considered valid if, when serialized with assumed attributes, the content matches the original value.”

That means three things must line up for a deprecation to validate:

  1. The deprecation’s attributes definition must parse the stored markup into the same attribute values the original save() used.
  2. The deprecation’s save() must serialize those attributes back into markup byte-identical to what is stored.
  3. The deprecation’s supports must produce the same wrapper class and attribute output the original block produced.

The documentation is direct about the inheritance trap: “attributes, supports, and save are not automatically inherited from the current version, since they can impact parsing and serialization of a block, so they must be defined on the deprecated object in order to be processed during a migration.” A deprecation that omits supports and relies on the current block’s supports will serialize a different wrapper. The comparison fails. The entry is skipped.

The same applies to sourced attributes. The attributes reference notes: “The block is responsible for using the save function to ensure that all attributes with a source field are saved according to the attributes definition. This is not automatic.” If your deprecation’s attributes declares a source: 'html' selector that no longer matches the stored markup — because the stored markup used a different tag, class, or nesting — the parsed attribute is empty or wrong, the serialized output diverges, and validation fails.

There is a second, quieter failure mode. If a deprecation’s save() imports a helper from the current codebase, and that helper changes, the deprecation’s historical output changes with it. The documentation warns: “if a deprecation’s save method imports additional functions from other files, changes to those files may accidentally change the behavior of the deprecation.” The deprecation was correct when you wrote it. It stopped being correct when someone edited a shared utility. Nothing in the deprecation file changed, so nothing in review flagged it.

Fix

Treat each deprecation entry as a frozen snapshot of a released block definition, not as a patch against the current one.

Reproduce the historical save() exactly

Copy the save() implementation from the released tag that produced the stored markup. Do not rewrite it from memory. Do not simplify it. The comparison is against the original serialized value, so the deprecation’s save() must produce that value, not an equivalent one.

// deprecated/v1/save.js
// Frozen copy of the save() from the release that produced the stored markup.
// Do not refactor. Do not import shared helpers that can change.
export default function save( { attributes } ) {
    return (
        <div className="notice">
            <p>{ attributes.text }</p>
        </div>
    );
}

Define attributes and supports on the deprecation

Do not rely on inheritance. The deprecation object must carry its own attributes and supports definitions, matching the release that produced the stored markup.

// deprecated/v1/index.js
import save from './save';

const attributes = {
    text: {
        type: 'string',
        source: 'html',
        selector: 'p',
    },
};

const supports = {
    className: false,
};

export default {
    attributes,
    supports,
    save,
    migrate( oldAttributes ) {
        return {
            content: oldAttributes.text,
            tone: 'info',
        };
    },
};

If the stored markup was produced with supports.className enabled, the deprecation must declare it. If the stored markup had no wrapper class, the deprecation must declare className: false. The serialized output is compared literally.

Order entries newest first

The documentation recommends reverse chronological order: “The deprecations in the array should be in reverse chronological order. This allows the block editor to attempt to apply the most recent and likely deprecations first, avoiding unnecessary and expensive processing.” The newest historical format is usually the most common in installed content, so it should be attempted first.

import v3 from './deprecated/v3';
import v2 from './deprecated/v2';
import v1 from './deprecated/v1';

export const deprecated = [ v3, v2, v1 ];

Add new migrations to every deprecation that can still match

Because only the first validating entry’s attributes are passed forward, a new migration must be present in every deprecation that can still match installed content. The documentation states this directly: “if you have several deprecations for a block and want to perform a new migration, like moving content to InnerBlocks, you may need to update the migrate methods in multiple deprecations in order for the required changes to be applied to all previous versions of the block.”

If v1 and v2 both validate against content still in the database, and you add a new attribute rename, both v1 and v2 need the rename in their migrate(). Adding it only to v2 leaves v1 content migrated to the v2 shape, not the current shape.

Use isEligible only for valid-but-stale content

The deprecation is tried when the parsed block is invalid, or when isEligible returns true. That second path exists for content that is technically valid but still needs normalization — a renamed enum value, a class that should be removed, an attribute that should be dropped.

isEligible( attributes ) {
    return attributes.alignment === 'centre';
},
migrate( attributes ) {
    return { ...attributes, alignment: 'center' };
},

Keep the predicate narrow. A broad isEligible that returns true for current content will cause the editor to re-run the migration on every load, which is both wasteful and a source of dirty-post state.

Verification

You cannot verify a deprecation by reading it. You verify it by feeding it the exact serialized markup it is supposed to recognize and confirming that the editor accepts it.

Keep fixtures per released format

The documentation recommends this: “It is also recommended to keep fixtures which contain the different versions of the block content to allow you to easily test that new deprecations and migrations are working across all previous versions of the block.” One fixture per public serialized format, captured from a real post created with the released build.

fixtures/
  v1.html
  v2.html
  v3.html

For each fixture, confirm four things:

  1. The editor loads it without an invalid-content warning.
  2. Attributes and inner blocks parse without losing user content.
  3. Saving converts it to the current serialized format.
  4. Reopening the migrated post remains valid.

Inspect the parsed attributes before the migration runs

In the editor console, you can inspect what the parser extracted from a stored block. The @wordpress/blocks package exposes getBlockAttributes, which “returns the block attributes of a registered block node given its type,” and getSaveContent, which “returns the static markup to be saved, given a block type containing a save render implementation and attributes.” You can use these to compare the deprecation’s parsed attributes and serialized output against the stored markup without guessing.

// In the editor console, with the block selected.
const { getBlockAttributes, getSaveContent, getBlockType } = wp.blocks;
const blockType = getBlockType( 'my-plugin/notice' );
const stored = wp.data.select( 'core/block-editor' )
    .getBlock( clientId ).originalContent;

const parsed = getBlockAttributes( blockType, stored );
const reserialized = getSaveContent( blockType, parsed );

console.log( { stored, parsed, reserialized, match: stored === reserialized } );

If match is false, the current block does not validate the stored content, and the deprecation loop will run. If you then swap in the deprecation’s attributes and save and match is still false, that deprecation will be skipped. That is the check to run before you touch migrate().

Check the database for stale markup

Block markup lives in wp_posts.post_content. You can count how many posts still contain the old serialized form before and after a migration pass.

SELECT ID, post_title
FROM wp_posts
WHERE post_status = 'publish'
  AND post_content LIKE '%<!-- wp:my-plugin/notice %'
  AND post_content LIKE '%<p>%'
ORDER BY post_modified DESC
LIMIT 20;

Adjust the LIKE patterns to the distinguishing markup of the old format. This tells you which posts still carry the old shape and whether the editor has rewritten them. It does not tell you whether the deprecation validated — only the editor can tell you that — but it tells you the scope of the problem.

WP-CLI for a content-level check

You can search post content for the old delimiter and attribute shape without opening the editor.

wp post list --post_type=post --post_status=publish \
  --field=ID \
  --meta_key=_does_not_exist \
  --format=ids | xargs -n1 -I{} wp post get {} --field=content \
  | grep -c 'wp:my-plugin/notice'

That count is a rough inventory. It is not a validation result. The validation result comes from the editor, because the editor is the only place where the block type’s save() and the deprecation’s save() are both registered and compared.

What to check before the next save() change

Before merging any change to save() or to a sourced attribute, answer these questions:

  1. Does the new save() regenerate markup identical to the latest released version? If not, is the markup change necessary rather than cosmetic?
  2. Does every historical deprecation still reproduce its own released markup, using its own attributes and supports?
  3. Does every released fixture migrate directly to the current format, without relying on another deprecation having run first?
  4. Does every deprecation that can still match installed content carry the latest migrate() changes?
  5. Does any deprecation import a helper that can change independently of the deprecation file?

If the answer to any of those is no, the deprecation will be skipped at validation time, and the migrate() you wrote will not run. The fix is not in migrate(). It is in the save() and the attribute definitions that gate it.

FAQ

Why does my migrate() never run even though isEligible returns true?

Because the deprecation’s save() did not produce valid content. The documentation states that a deprecation whose save() does not produce a valid block is skipped completely, including its migrate(), even if isEligible would return true. The isEligible check is not a bypass around validation; it is an additional trigger for entries that would otherwise not be tried because the block is already valid.

Can I rely on the current block’s attributes and supports in a deprecation?

No. The documentation is explicit that attributes, supports, and save are not automatically inherited from the current version, because they affect parsing and serialization. Each deprecation must define them.

Do deprecations run in sequence, each building on the previous one?

No. The documentation states that deprecations do not operate as a chain of updates the way database migrations do. Each entry is tried against the original saved content until one produces valid content. Only that entry’s attributes and inner blocks are passed forward to the current save().

What order should the deprecated array be in?

Reverse chronological order, newest first. The documentation recommends this so the editor attempts the most recent and likely deprecations first, avoiding unnecessary processing.

How do I know which deprecation is failing?

You do not get a direct signal from the editor. The practical approach is to reproduce the stored markup against each deprecation’s attributes and save() in isolation, using getBlockAttributes and getSaveContent from @wordpress/blocks, and confirm that the serialized output matches the stored content. The first entry that matches is the one the editor will use. If none match, the block stays invalid.

Is isValidBlockContent still the right function to check?

The @wordpress/blocks reference marks isValidBlockContent as deprecated and says to use validateBlock instead to avoid data loss. The validity condition it describes is the same: a block is valid if, when serialized with assumed attributes, the content matches the original value.

Related

If you are still at the stage where a fresh install shows nothing on the front end, the failure is earlier in the stack than block validation. What to Fix First When a New WordPress Site Says Nothing Found covers the front-end rendering path before you start debugging the editor.

Sources

Why Your Custom Post Type Is Missing from the REST Search Endpoint: WP_REST_Search_Handler, the wp_rest_search_handlers Filter, and the post/page Default

Your custom post type has a working REST route. GET /wp-json/wp/v2/your-cpt returns a tidy collection. The block editor loads it. Then you query GET /wp-json/wp/v2/search?search=quarterly and get back posts, pages, maybe a category term, and nothing from your CPT. The instinct is to reach for wp_rest_search_handlers and register a handler. That instinct is usually wrong, and acting on it can duplicate results rather than fix them.

The /wp/v2/search endpoint does not enumerate post types directly. It delegates to a registry of search handlers. One of those handlers, WP_REST_Post_Search_Handler, already covers every public post type that is exposed to REST. If your CPT is missing, the problem is almost always in the post type registration flags, not in the handler list.

Symptom

A request to the search endpoint returns results whose subtype values are post and page, but never your CPT slug. The same CPT appears correctly at its own collection route. The block editor’s search panel, if it uses the search endpoint, also omits it.

Two separate registries are involved, and conflating them is the source of most wasted debugging time:

  • The handler list. A site-wide array of WP_REST_Search_Handler instances, filtered by wp_rest_search_handlers. The documentation states the default is only a handler for posts. That handler’s type is post.
  • The subtype list. Built once inside WP_REST_Post_Search_Handler::__construct() from get_post_types( array( 'public' => true, 'show_in_rest' => true ), 'names' ), minus attachment.

The handler list is global and rarely needs changing. The subtype list is built once, when the default post search handler is instantiated during REST route registration, not per request. This is exactly why a register_post_type() call on a late or conditional hook can miss the list. A CPT that is absent from search results but present at its own route points at the flags, not the handler list.

Trace

Start by reading the constructor of the default post search handler. The relevant lines are short:

public function __construct() {
    $this->type = 'post';
    // Support all public post types except attachments.
    $this->subtypes = array_diff(
        array_values( get_post_types( array( 'public' => true, 'show_in_rest' => true ), 'names' ) ),
        array( 'attachment' )
    );
}

Two flags gate inclusion: public and show_in_rest. Both must be true. The register_post_type() documentation is explicit that show_in_rest controls whether the post type is included in the REST API, and that public defaults to false. A CPT registered with only show_in_rest => true and no public argument will not appear in the subtype list.

When a search request arrives with subtype any, search_items() replaces the requested post types with the handler’s subtype list:

if ( in_array( WP_REST_Search_Controller::TYPE_ANY, $post_types, true ) ) {
    $post_types = $this->subtypes;
}

That is the mechanism. If your CPT is not in $this->subtypes, it is not queried. The handler then runs a WP_Query with 'post_status' => 'publish'. A correctly registered CPT whose entries are all drafts will also appear missing. Separate the registration question from the status question before changing code.

The search results schema lists subtype values as post, page, category, and post_tag. That list is illustrative of core defaults, not exhaustive of every handler a site may register. The type parameter on the endpoint defaults to post, which is why a bare search request without a type or subtype argument behaves as a post search.

Root cause

The default post search handler already enumerates every public, REST-exposed post type. Adding a custom handler to wp_rest_search_handlers to fix a missing CPT is likely to duplicate results, because the default handler is already covering the same post type when the flags are set correctly. The filter is for object types the post handler cannot represent, not for post types it already covers.

The wp_rest_search_handlers filter is applied in create_initial_rest_routes(), which registers the default REST API routes. Each handler instance passed through the filter must extend WP_REST_Search_Handler. That is a real extension point, but it is not the first place to look when a CPT is absent.

Two failure modes produce the same symptom:

  1. Registration flags. public is false or omitted, or show_in_rest is false or omitted. The CPT is not in the subtype list, so the default handler never queries it.
  2. Status. The CPT is registered correctly, but all its entries are drafts, pending, or otherwise not publish. The handler queries with post_status => 'publish', so nothing returns.

A third, less common case: the CPT is registered on a hook that runs too late for the handler’s constructor, or the registration is conditional on a context that is not true during REST requests. The constructor runs when the handler is instantiated, which is during REST route registration. If your register_post_type() call is hooked to something that has not fired by then, the subtype list will not include it.

Fix

Diagnose in order. Do not start with the filter.

1. Inspect the registered post type object

Use WP-CLI to confirm the flags as WordPress sees them:

wp eval '
$obj = get_post_type_object( "your-cpt" );
if ( ! $obj ) {
    echo "not registered\n";
    exit;
}
echo "public: " . var_export( $obj->public, true ) . "\n";
echo "show_in_rest: " . var_export( $obj->show_in_rest, true ) . "\n";
echo "rest_base: " . $obj->rest_base . "\n";
'

If public or show_in_rest is false, that is your root cause. Fix the registration arguments. Both must be true for the default post search handler to include the CPT.

2. Inspect the search response’s subtype field

Request the search endpoint with the subtype set to your CPT slug and compare against the subtype values the response reports:

curl -s 'https://example.com/wp-json/wp/v2/search?search=quarterly&subtype=your-cpt' | jq '.subtype'

If the response is an empty array or an error, the subtype is not recognized. If it returns results, the registration is fine and the earlier absence was a status or query issue. The subtype field in the response is the observable that distinguishes “not registered as searchable” from “registered but empty.”

3. Inspect the handler list

Only after the first two checks should you look at the handler registry. A quick eval confirms what is registered:

wp eval '
$handlers = apply_filters( "wp_rest_search_handlers", array() );
foreach ( $handlers as $handler ) {
    echo get_class( $handler ) . " => " . $handler->get_type() . "\n";
    print_r( $handler->get_subtypes() );
}
'

If the default post handler is present and its subtype list includes your CPT, the handler list is not your problem. If the subtype list does not include your CPT, return to step 1.

4. When a custom handler is actually warranted

A custom handler is appropriate when you need to search an object type the post handler cannot represent: a custom database table, an external index, a non-post entity. It is not appropriate for a CPT that the post handler already covers.

WP_REST_Search_Handler declares three abstract methods that a subclass must implement:

  • search_items( WP_REST_Request $request ) — returns an associative array with RESULT_IDS (an array of found IDs) and RESULT_TOTAL (the total count).
  • prepare_item( $id, array $fields ) — returns the fields for a given item.
  • prepare_item_links( $id ) — returns links for a given item.

The base class also provides get_type() and get_subtypes(), which return the $type and $subtypes properties set by the subclass. A minimal skeleton:

class My_CPT_Search_Handler extends WP_REST_Search_Handler {
    public function __construct() {
        $this->type = 'my-cpt';
        $this->subtypes = array( 'my-cpt' );
    }

    public function search_items( WP_REST_Request $request ) {
        $query = new WP_Query( array(
            'post_type' => 'my-cpt',
            'post_status' => 'publish',
            's' => $request['search'],
            'paged' => (int) $request['page'],
            'posts_per_page' => (int) $request['per_page'],
        ) );
        return array(
            self::RESULT_IDS => wp_list_pluck( $query->posts, 'ID' ),
            self::RESULT_TOTAL => $query->found_posts,
        );
    }

    public function prepare_item( $id, array $fields ) {
        $post = get_post( $id );
        $data = array();
        if ( in_array( WP_REST_Search_Controller::PROP_ID, $fields, true ) ) {
            $data[ WP_REST_Search_Controller::PROP_ID ] = (int) $post->ID;
        }
        if ( in_array( WP_REST_Search_Controller::PROP_TITLE, $fields, true ) ) {
            $data[ WP_REST_Search_Controller::PROP_TITLE ] = get_the_title( $post->ID );
        }
        if ( in_array( WP_REST_Search_Controller::PROP_URL, $fields, true ) ) {
            $data[ WP_REST_Search_Controller::PROP_URL ] = get_permalink( $post->ID );
        }
        if ( in_array( WP_REST_Search_Controller::PROP_TYPE, $fields, true ) ) {
            $data[ WP_REST_Search_Controller::PROP_TYPE ] = $this->type;
        }
        if ( in_array( WP_REST_Search_Controller::PROP_SUBTYPE, $fields, true ) ) {
            $data[ WP_REST_Search_Controller::PROP_SUBTYPE ] = $post->post_type;
        }
        return $data;
    }

    public function prepare_item_links( $id ) {
        $post = get_post( $id );
        $links = array();
        $item_route = rest_get_route_for_post( $post );
        if ( ! empty( $item_route ) ) {
            $links['self'] = array(
                'href' => rest_url( $item_route ),
                'embeddable' => true,
            );
        }
        $links['about'] = array(
            'href' => rest_url( 'wp/v2/types/' . $post->post_type ),
        );
        return $links;
    }
}

add_filter( 'wp_rest_search_handlers', function( $handlers ) {
    $handlers[] = new My_CPT_Search_Handler();
    return $handlers;
} );

Register this only when the default post handler genuinely cannot cover the object type. If you register it for a CPT that is already public and REST-exposed, you will get duplicate entries in search results.

Verification

Confirm the subtype appears in a live search response before and after the registration change. A minimal check:

curl -s 'https://example.com/wp-json/wp/v2/search?search=quarterly&subtype=your-cpt' | jq '.subtype'

Before the fix, expect an empty array or an error. After correcting the registration flags, expect the CPT slug to appear in the subtype values. If you added a custom handler, verify that the default post handler’s results are not duplicated.

For a broader diagnostic sequence on empty search results, see What to Fix First When a New WordPress Site Says Nothing Found. The same order applies here: check registration and status before touching the handler registry.

FAQ

Does the search endpoint search all post types by default?

No. The type parameter defaults to post, and the default post search handler’s subtype list is built from public, REST-exposed post types. A CPT that is not public or not exposed to REST will not appear.

Why does my CPT appear at its own REST route but not in search?

The collection route for a CPT is registered by WP_REST_Posts_Controller when the post type has show_in_rest true. The search endpoint uses a separate handler whose subtype list requires both public and show_in_rest to be true. A CPT with show_in_rest true but public false can have a working collection route and still be absent from search.

Can I add my CPT to search with the wp_rest_search_handlers filter?

You can, but you should not need to for a standard CPT. The default post handler already covers public, REST-exposed post types. Adding a handler for a CPT the default handler already covers will produce duplicate results. Use the filter for object types the post handler cannot represent.

What if my CPT entries are all drafts?

The default post search handler queries with post_status => 'publish'. Drafts, pending, and private entries will not appear. This is a status issue, not a registration issue. Publish at least one entry and retest before changing code.

Where is the wp_rest_search_handlers filter applied?

It is applied in create_initial_rest_routes(), which registers the default REST API routes. Each handler instance passed through the filter must extend WP_REST_Search_Handler.

What methods must a custom search handler implement?

WP_REST_Search_Handler declares search_items(), prepare_item(), and prepare_item_links() as abstract. search_items() must return an array with RESULT_IDS and RESULT_TOTAL keys.

Sources

The Caution Triangle as a Contract: Reading the ‘Not Friendly Do Not Touch’ Warning Sign

Editorial note: This publication and Quirky Cotton Collective share an owner, so treat what follows as an owner-authorized reading of the artwork rather than an independent review.

The graphic is a warning sign. Not a joke about a warning sign — the sign itself, borrowed whole. Across the chest, in the block lettering you would find on a wet-floor placard or a high-voltage cabinet, the words NOT FRIENDLY sit above DO NOT TOUCH. Between them, or beside them depending on the print, a yellow caution triangle holds a hand with a diagonal bar through it. The palette is the standard hazard vocabulary: yellow field, black mark, heavy sans-serif capitals. Nothing about the composition asks to be read as a slogan. It asks to be read as signage.

That distinction is the whole argument. A slogan is a claim about the wearer — I am this. A sign is a claim about the environment — this surface behaves this way. The not friendly do not touch shirt works because it chooses the second register. It does not say the wearer is an introvert, or tired, or busy. It says the wearer is a surface with known properties, and the properties are posted in advance.

What the sign actually promises

Warning signs are interesting objects because they are contracts written in advance of the event they describe. The yellow triangle does not prevent the fall. It transfers information: this condition exists, and you are now responsible for how you respond to it. The crossed-out hand is the enforcement clause. It does not say touching is impossible. It says touching is outside the terms.

Read that way, the shirt is doing something more precise than the usual introvert merchandise. The common version of this genre says I am shy or I need a minute — statements about a person’s interior state, which the viewer is invited to find charming or relatable. This design skips the interior entirely. It posts a boundary as a fact about the exterior. The wearer does not have to explain, apologize, or perform a mood. The sign has already done the disclosure, in the least ambiguous typography available.

I find that more honest than the alternative. A shirt that says sorry, I’m awkward asks the room for accommodation. A shirt that says DO NOT TOUCH states a rule. Rules are easier to follow than moods, because they do not require the reader to interpret anything.

Why the caution triangle carries the design

The triangle is doing the heavy lifting, and it is worth being specific about why. Hazard signage is one of the few visual languages that has been standardized across contexts — industrial floors, roadways, equipment panels, chemical storage. You have seen this triangle your entire life, and you have never once had to decode it. The shape means attention. The yellow means caution, not prohibition. The black mark inside means the specific hazard.

When that vocabulary is transplanted onto a t-shirt, it arrives pre-loaded. The viewer does not need to read the words to get the message; the triangle has already set the tone before the lettering resolves. That is efficient design. It also explains why the crossed-out hand reads as a prohibition rather than a rejection — the hand is a hazard symbol, not a person. The sign is not saying you are unwelcome. It is saying contact is the hazard.

That is a meaningful difference, and I think it is why the design avoids the misanthropy that this kind of shirt can easily slide into. A crossed-out hand in a caution triangle is clinical. It is the same mark you would find on a hot surface or a moving part. It does not editorialize about the person approaching. It describes the surface.

The difference between a slogan and a usable promise

Most boundary humor fails because it overpromises. A shirt that says I hate everyone is a slogan: it makes a claim the wearer cannot actually honor, because the wearer will inevitably need to interact with someone, and the shirt will then be revealed as a bit. The bit is fine. The bit is not a boundary.

The warning-sign framing avoids that failure mode because it is scoped. NOT FRIENDLY is not the same claim as hostile. It is a statement about default mode, not about capacity. DO NOT TOUCH is not the same claim as do not speak to me. It is a statement about one specific channel. The sign is narrow on purpose. Narrow claims are the ones that survive contact with reality.

This is the part I keep coming back to when I look at the design. It is a piece of clothing that behaves like documentation. It states the condition, states the constraint, and stops. No explanation, no apology, no invitation to negotiate. If you have ever written a README that says this function does not validate input and then declined to add a paragraph about why, you recognize the instinct. The sign is the docstring. The reader can take it or leave it.

What the shirt is not

It is not a security control. A printed warning does not enforce anything, and I would not want to imply that wearing a caution triangle changes how anyone behaves. It is a disclosure, not a mechanism. The distinction matters because the design’s appeal is precisely that it does not pretend to be more than a disclosure. It is a sign. Signs inform. They do not compel.

It is also not a personality test. The design does not claim the wearer is introverted, and the retail title’s use of that word is a search-facing description, not a printed element. What is printed is the warning language and the symbol. Everything else is context the viewer brings.

And it is not subtle. That is the point. The typography is heavy, the triangle is bright, the message is short. If you are going to post a boundary on your torso, the least you can do is make it legible from across a room.

The case for the sign over the confession

There is a version of this shirt that would have been a confession: I’m not friendly, sorry, in a soft script, with a little shrug. That shirt would have been fine. It would also have been a request. The version that exists is a notice. It does not ask the viewer to be understanding. It tells the viewer what the surface is.

I prefer the notice. Not because I think boundaries should be cold, but because I think they should be clear. Clarity is a kindness. A sign that says exactly what it means, in the visual language everyone already knows, is doing the viewer a favor. It removes the guesswork. It removes the need for the wearer to explain. It removes the awkward middle where both parties are trying to figure out what the other one wants.

The caution triangle is not decoration. It is the part of the design that turns a sentence into a system. And a system, even a small one printed on cotton, is more useful than a mood.

Why Posts Sit at ‘Missed Schedule’: The _transient_doing_cron Lock, WP_CRON_LOCK_TIMEOUT, and the spawn_cron() Gates

A post with a past timestamp and a status of future is not a post problem. The editor labels it Missed Schedule because WordPress expected the post to be published by a scheduled event, and that event has not run. The post record is fine. The spawn path is not.

This is a trace of that spawn path: how wp_cron() registers the runner, how spawn_cron() uses the doing_cron transient as a lock, what WP_CRON_LOCK_TIMEOUT gates, and why the distinction between a held lock and a stale lock determines your next move.

Symptom

You open a post that was scheduled for 09:00. It is now 09:40. The editor shows Missed Schedule. The wp_posts row still has post_status = 'future' and a post_date_gmt in the past. Nothing in the post table explains why. The explanation lives in the cron option and the transient that guards it.

Two things must both be true for the post to publish:

  1. The publish event must exist in the cron array with a timestamp that has passed.
  2. Something must actually invoke wp-cron.php so that event is drained.

Missed Schedule means one of those failed. The label does not tell you which.

Trace: how the spawn path is wired

WordPress does not run cron on a system timer by default. It runs it when a page request finishes. The registration happens in wp_cron(), which the documentation describes as registering _wp_cron() to run on the shutdown action. The same documentation notes that as of 6.9.0 the callback moved from wp_loaded to shutdown unless ALTERNATE_WP_CRON is enabled, and that the function now always returns void.

The source shown in the reference is explicit about the branch:

function wp_cron(): void {
    if ( defined( 'ALTERNATE_WP_CRON' ) && ALTERNATE_WP_CRON ) {
        if ( did_action( 'wp_loaded' ) ) {
            _wp_cron();
        } else {
            add_action( 'wp_loaded', '_wp_cron', 20 );
        }
    } elseif ( doing_action( 'shutdown' ) ) {
        _wp_cron();
    } else {
        add_action( 'shutdown', '_wp_cron' );
    }
}

So on a normal install, _wp_cron() runs at shutdown. Under ALTERNATE_WP_CRON, it runs at wp_loaded. That difference matters when you are reading a slow page and a missed schedule at the same time.

_wp_cron() is the function that decides whether to run due events inline or to spawn a loopback request. The spawning itself is spawn_cron(), documented as sending a request to run cron through an HTTP request that does not halt page loading.

Trace: the lock arithmetic in spawn_cron()

Before spawn_cron() fires anything, it reads the doing_cron transient and treats it as a float. The reference source shows the guard:

$lock = (float) get_transient( 'doing_cron' );
if ( $lock > $gmt_time + 10 * MINUTE_IN_SECONDS ) {
    $lock = 0;
}

// Don't run if another process is currently running it or more than once every 60 sec.
if ( $lock + WP_CRON_LOCK_TIMEOUT > $gmt_time ) {
    return false;
}

Three things are happening here.

First, the transient is read as a float. It is written as a microsecond-precision string, so the cast is not cosmetic.

Second, a lock more than ten minutes in the future is treated as zero. That is a sanity check against a clock skew or a corrupted value, not a timeout.

Third, the real gate: if $lock + WP_CRON_LOCK_TIMEOUT is still ahead of the current time, spawn_cron() returns false and does nothing. The comment in core describes this as preventing a second run while another process is running it, or more than once every sixty seconds.

The constant WP_CRON_LOCK_TIMEOUT is referenced here but its value is not stated in the retrieved reference. Do not assume a number. Read it from your install:

wp eval 'var_dump( WP_CRON_LOCK_TIMEOUT );'

If that returns nothing, the constant is not defined in your bootstrap and you are looking at a different problem than the one this article covers.

Trace: the second gate, the ready queue

Even with a clear lock, spawn_cron() will not spawn if there is nothing ready to run. The reference source shows two more returns:

$crons = wp_get_ready_cron_jobs();
if ( empty( $crons ) ) {
    return false;
}

$keys = array_keys( $crons );
if ( isset( $keys[0] ) && $keys[0] > $gmt_time ) {
    return false;
}

This is the gate that produces the most confusing version of the symptom. If every event in the cron array is dated in the future, wp_get_ready_cron_jobs() returns an empty set and spawn_cron() exits without a loopback. Nothing is broken. There is simply nothing due.

That is why a missed schedule investigation should always start by reading the cron option, not by clearing transients.

Trace: the loopback request itself

Once the lock is clear and the ready queue is non-empty, spawn_cron() writes the lock and builds the request. The reference source shows the write:

// Set the cron lock with the current unix timestamp, when the cron is being spawned.
$doing_wp_cron = sprintf( '%.22F', $gmt_time );
set_transient( 'doing_cron', $doing_wp_cron );

Then it builds the URL and the request arguments. The defaults are worth reading closely:

'args' => array(
    'timeout'   => 0.01,
    'blocking'  => false,
    'sslverify' => apply_filters( 'https_local_ssl_verify', false, $cron_url ),
),

A 0.01 second timeout, non-blocking, SSL verification off for the local request. The cron_request filter lets you change all of it.

The wp_cron() documentation adds a caveat that matters for diagnosis: wp_remote_post() does not always respect the timeout and blocking parameters, and a timeout of 0.01 may end up taking one second. The same documentation explains that this is why the callback moved to shutdown: running it at wp_loaded increased Time To First Byte because the HTML could not be sent while waiting for the cron request to initiate.

So the loopback is a hint, not a guarantee. It may complete in milliseconds. It may take a second. It may be dropped by a host that blocks loopback requests. None of that is visible from the editor.

Root cause: held lock versus stale lock

From the post editor, a held lock and a stale lock look identical. Both produce Missed Schedule. They require opposite responses.

A held lock means another process is mid-run. The transient was written by a spawn_cron() call that has not yet been followed by a completed wp-cron.php run. The correct action is to wait, then re-check. If the lock clears and the event drains, nothing was wrong except timing.

A stale lock means the transient outlived its process. The PHP worker that wrote it died, or the loopback never reached wp-cron.php, or wp-cron.php started and was killed before it could clear the lock. The transient is now a tombstone. Until it ages out past WP_CRON_LOCK_TIMEOUT, spawn_cron() will keep returning false and no new loopback will fire.

You cannot tell which one you have by looking at the post. You have to read the transient and compare it to the current time.

Diagnostic sequence

Run these in order. Stop when you find the answer.

1. Read the lock

wp transient get doing_cron

If it returns a float, compare it to time(). If the value is in the past by more than WP_CRON_LOCK_TIMEOUT, the lock is not the blocker. If it is in the future, note by how much.

You can also read it directly:

wp db query "SELECT option_name, option_value, FROM_UNIXTIME(option_value) FROM wp_options WHERE option_name IN ('_transient_doing_cron','_transient_timeout_doing_cron');"

The timeout row tells you when the transient will expire on its own. That is the window during which spawn_cron() will keep bailing.

2. Confirm the event exists and is overdue

wp cron event list --fields=hook,next_run_relative,next_run_gmt,recurrence

Find the publish_future_post entry for your post. If it is not there, the problem is upstream of the lock: the event was never scheduled, or it was scheduled with mismatched arguments and exists under a different key.

The wp_schedule_event() documentation warns about exactly this. Arguments are used to uniquely identify the scheduled event and must match those used when the event was originally scheduled. If they do not match exactly, WordPress treats the event as different, which can lead to duplicate events and growth of the cron option.

Check for duplicates before you assume the lock is the cause:

wp cron event list --hook=publish_future_post --fields=hook,next_run_gmt,args

3. Check the ready queue

wp eval 'print_r( wp_get_ready_cron_jobs() );'

If this is empty while wp cron event list shows overdue events, you have a mismatch between what is stored and what is considered ready. That is a different failure than the lock.

4. Test the loopback

curl -sS -o /dev/null -w '%{http_code} %{time_total}\n' "https://example.com/wp-cron.php?doing_wp_cron"

Replace the host. A 200 with a short time means the endpoint is reachable. A 403, 401, or a timeout means the loopback is blocked and spawn_cron() will keep writing locks that never get cleared.

5. Check the constants

wp eval 'var_dump( defined("DISABLE_WP_CRON") ? DISABLE_WP_CRON : null, defined("ALTERNATE_WP_CRON") ? ALTERNATE_WP_CRON : null );'

If DISABLE_WP_CRON is true, the spawn path is intentionally off and something else must be invoking wp-cron.php. If ALTERNATE_WP_CRON is true, the callback runs at wp_loaded, not shutdown, and the TTFB symptoms differ.

6. Read the cron option directly

wp option get cron --format=json | head -c 2000

This is the raw array. If it is enormous, you have duplicate events and the ready-queue gate is harder to reason about. If it is empty, nothing is scheduled at all.

Fix

The fix depends on which of the two lock states you found.

If the lock is held and recent: wait. Re-check the transient after WP_CRON_LOCK_TIMEOUT has passed. If the event drains, the system worked. If it does not, the lock is stale and you have moved to the next case.

If the lock is stale: clear it and let the next page request spawn a fresh loopback.

wp transient delete doing_cron

Then trigger a request to the site and confirm the event drains:

curl -sS -o /dev/null "https://example.com/"
wp cron event list --hook=publish_future_post --fields=hook,next_run_gmt

If the event is gone and the post is published, the stale lock was the cause. If the event is still there, the loopback is not reaching wp-cron.php and you need to fix that before clearing transients again.

If the ready queue is empty while events are overdue: the lock is not your problem. Inspect the cron option for argument mismatches and duplicates, and reconcile the scheduled events against the hooks your code actually registers.

If the loopback is blocked: either fix the host configuration so wp-cron.php is reachable, or define DISABLE_WP_CRON and drive wp-cron.php from a real system cron. The wp_schedule_event() documentation notes that the default behavior of waiting for a visitor may not suit resource-intensive hooks, and suggests disabling internal cron and fetching the URL from the host control panel or executing the file directly with PHP.

Verification

After the fix, confirm three things:

  1. The transient is gone or has been rewritten with a current timestamp.
  2. The overdue event is no longer in wp cron event list.
  3. The post status is publish, not future.
wp transient get doing_cron
wp cron event list --hook=publish_future_post
wp post get 123 --field=post_status

If all three are clean, the spawn path completed. If the transient reappears with a fresh timestamp but the event does not drain, the loopback is firing and wp-cron.php is not completing. That is a different trace, and it starts with the server error log, not the database.

What this does not cover

The retrieved reference for spawn_cron() and wp_cron() does not include the body of wp-cron.php itself, nor the _get_cron_lock() helper. Claims about how wp-cron.php iterates the queue and clears the lock are outside the evidence used here. If your investigation reaches the point where the loopback fires and the lock is rewritten but events still do not run, you are past the scope of this trace and into the internals of the cron runner.

The pre_schedule_event filter is worth knowing about because it can short-circuit scheduling entirely. The hook reference states that returning a non-null value will short-circuit adding the event to the cron array, causing the function to return the filtered value instead. If a plugin on your install returns a value from that filter for publish_future_post, the event never enters the array and no lock state will explain the missed schedule.

FAQ

Does clearing the doing_cron transient always fix a missed schedule?

No. It only helps when the lock is stale. If the lock is held by a running process, clearing it can cause a second concurrent run. If the ready queue is empty, clearing the lock changes nothing.

Why does the editor say Missed Schedule instead of showing an error?

The label reflects that the scheduled publish event did not fire. It does not distinguish between a lock problem, a loopback problem, and a missing event. That distinction is what the diagnostic sequence above is for.

What is WP_CRON_LOCK_TIMEOUT set to?

The retrieved reference for spawn_cron() uses the constant but does not state its value. Read it from your install with wp eval 'var_dump( WP_CRON_LOCK_TIMEOUT );' rather than assuming a number.

Does the 6.9.0 shutdown change affect missed schedules?

It changes where the spawn attempt happens in the request lifecycle. Under the default path, _wp_cron() runs at shutdown, so the loopback fires after the HTML is flushed. Under ALTERNATE_WP_CRON, it runs at wp_loaded, which the documentation notes increases TTFB. If you are correlating slow pages with missed schedules, check which branch your install is on.

Can duplicate cron events cause missed schedules?

They can make the ready queue harder to reason about and inflate the cron option. The wp_schedule_event() documentation warns that mismatched arguments cause WordPress to treat events as different, leading to duplicates and database performance issues. Check for duplicates before concluding the lock is the cause.

Sources

wp_enqueue_block_style(): What It Actually Does With the path Argument, and Why the Editor Symptom Is a Hypothesis

You registered a block stylesheet with wp_enqueue_block_style(). The front end looks correct. The editor does not. The obvious next move is to search for a missing argument, find path in the function signature, add it, and declare victory. That move is not supported by the function source. The editor symptom and the path argument are two separate questions, and the function body answers them in a specific order.

This is a control-flow walkthrough. The goal is to separate the branch that decides when the stylesheet loads from the data that decides what core can do with it. The proposed framing — that the editor ignores the stylesheet without path — is not established by the retrieved documentation or source. Treat it as a hypothesis to test, not a mechanism to assume.

Symptom

A block stylesheet registered through wp_enqueue_block_style() renders on the front end but appears absent in the block editor. The two candidate causes are structurally different:

  1. Branch selection. The function attaches its callback to different hooks depending on whether block assets are loaded on demand. If the on-demand branch is active, the callback is attached to render_block and never reaches the editor registration in the non-on-demand branch.
  2. Missing path data. The path argument feeds wp_style_add_data( $handle, 'path', $path ) and the RTL file swap. It is documented as the absolute path to the stylesheet “so that it can potentially be inlined.” It is not documented as a gate on editor loading.

Before changing anything, determine which branch is active on the request that renders the editor. The rest of this article walks the function body in the order it executes.

Trace: the function body in order

The reference page documents wp_enqueue_block_style( string $block_name, $args ) as enqueuing a stylesheet for a specific block. The description states: “If the theme has opted-in to load block styles on demand, then the stylesheet will be enqueued on-render, otherwise when the block inits.”

The source begins with argument defaults:

$args = wp_parse_args( $args, array(
    'handle' => '',
    'src'    => '',
    'deps'   => array(),
    'ver'    => false,
    'media'  => 'all',
) );

Note what is not in the defaults: path. It is optional. The shared callback then registers the style, conditionally adds path and RTL data, and enqueues:

$callback = static function ( $content ) use ( $args ) {
    if ( ! empty( $args['src'] ) ) {
        wp_register_style( $args['handle'], $args['src'], $args['deps'], $args['ver'], $args['media'] );
    }

    // Add `path` data if provided.
    if ( isset( $args['path'] ) ) {
        wp_style_add_data( $args['handle'], 'path', $args['path'] );

        // Get the RTL file path.
        $rtl_file_path = str_replace( '.css', '-rtl.css', $args['path'] );

        // Add RTL stylesheet.
        if ( file_exists( $rtl_file_path ) ) {
            wp_style_add_data( $args['handle'], 'rtl', 'replace' );
            if ( is_rtl() ) {
                wp_style_add_data( $args['handle'], 'path', $rtl_file_path );
            }
        }
    }

    wp_enqueue_style( $args['handle'] );
    return $content;
};

Two facts follow directly from this excerpt. First, path is only consumed when set; when absent, no path data is attached and no RTL swap occurs. Second, the documented purpose of path is inlining, and the source shows it also driving the RTL file existence check and the is_rtl() swap. That makes path a data-completeness argument, not a loading switch.

The branch that decides when the callback runs

After the callback is defined, the function selects a hook based on whether wp_enqueue_scripts has already fired:

$hook = did_action( 'wp_enqueue_scripts' ) ? 'wp_footer' : 'wp_enqueue_scripts';

Then it checks the on-demand condition:

if ( wp_should_load_block_assets_on_demand() ) {
    $callback_separate = static function ( $content, $block ) use ( $block_name, $callback ) {
        if ( ! empty( $block['blockName'] ) && $block_name === $block['blockName'] ) {
            return $callback( $content );
        }
        return $content;
    };

    add_filter( 'render_block', $callback_separate, 10, 2 );
    return;
}

add_filter( $hook, $callback );

// Enqueue assets in the editor.
add_action( 'enqueue_block_assets', $callback );

The reference page documents wp_should_load_block_assets_on_demand() as checking “whether block styles should be loaded only on-render.” When that check returns true, the function attaches the wrapper to render_block and returns. The wp_enqueue_scripts/wp_footer registration and the enqueue_block_assets registration are never reached on that call.

This is the practical debugging lever. The two branches are mutually exclusive at call time. Any advice that assumes the editor registration is active is conditional on wp_should_load_block_assets_on_demand() returning false.

The render_block filter is documented in the Block Editor handbook as filtering “the front-end content of any block” and as having “no impact on the behavior of blocks in the Editor.” That is consistent with the source: in the on-demand branch, the callback is attached to a front-end render filter, and the editor registration is skipped.

What the source does and does not establish about the editor

The retrieved source shows an enqueue_block_assets registration in the non-on-demand branch, with the comment “Enqueue assets in the editor.” It does not show any condition under which a missing path argument prevents that registration from running. The documentation describes path as feeding inlining data. Nothing retrieved states that path is required for the stylesheet to load in the editor at all.

So the claim “the editor ignores the stylesheet without the path argument” is not supported by the sources retrieved for this article. The honest framing is: the editor symptom has at least two candidate causes, and the function body gives you a way to distinguish them. Do not skip the branch check because the path argument looks like the missing piece.

Root cause candidates, ordered by cost to check

1. The on-demand branch is active and the editor registration is unreachable

If wp_should_load_block_assets_on_demand() returns true, the function returns after attaching to render_block. The editor registration in the non-on-demand branch does not execute. A user-contributed note on the reference page states that to load block CSS only when the block is present on the page, you should filter should_load_separate_core_block_assets to return true. The same note says this “should happen automatically if you are using FSE,” but that sentence is hedged and version-unspecified; treat it as a lead, not a rule.

Check which branch is active before touching path:

add_action( 'init', function () {
    error_log( 'on-demand: ' . ( wp_should_load_block_assets_on_demand() ? 'true' : 'false' ) );
} );

Or, without editing code, use WP-CLI to inspect the filter state on a request that renders the editor. The exact command depends on your setup; the point is to observe the boolean, not to guess it.

2. The handle collides with a handle core already uses

A user-contributed note warns: “If your block is called ‘namespace/name’, do not set handle argument to ‘namespace-name-style’, because that style is already used by the WordPress to enqueue block styles and style will not be enqueued.” This produces a silent non-enqueue with no error. It is a cheap first check.

Inspect registered styles for the handle before and after your registration:

wp eval 'global $wp_styles; var_dump( isset( $wp_styles->registered["namespace-name-style"] ) );'

If the handle is already registered by core, rename yours. The collision is not a bug in your code; it is a namespace you do not own.

3. The callback is hooked to the same hook the function uses internally

A user-contributed note states: “This function can be called directly, without hooking it to specific action, because it hooks enqueuing internally to enqueue_block_assets. If it is hooked to the same hook, it will not work.” If you wrapped the call in add_action( 'enqueue_block_assets', ... ), the internal registration and your wrapper compete on the same hook. Call the function directly, or hook it to after_setup_theme as the contributed example does.

4. The block name never appears in rendered block content

In the on-demand branch, the wrapper compares $block['blockName'] to the registered $block_name. A stylesheet registered for a block name that never appears in rendered block content will not be enqueued on the front end at all. This is a plausible source of “my styles are missing” reports that has nothing to do with path. Confirm the block name matches the registered name exactly, including namespace.

5. Path data is absent, so core cannot inline or resolve the RTL file

If the branch is correct, the handle is unique, and the block name matches, then path is worth setting — not because it gates editor loading, but because it is the data core uses for inlining and for the RTL swap. Without it, wp_style_add_data( $handle, 'path', $path ) is never called, and the -rtl.css existence check never runs.

wp_enqueue_block_style( 'namespace/name', array(
    'handle' => 'namespace-name-block-style',
    'src'    => get_theme_file_uri( 'assets/css/blocks/name.css' ),
    'path'   => get_theme_file_path( 'assets/css/blocks/name.css' ),
) );

The path value is an absolute filesystem path, not a URL. The src value is the URL. Mixing them up is a common source of confusion; the source uses path only for file_exists() and for the style data key.

Fix

Order the fix by the checks above. Do not start with path.

  1. Confirm which branch is active. If on-demand is true, the editor registration is not reached on that call. Decide whether that is the behavior you want before changing anything else.
  2. Confirm the handle is not already registered by core. Rename if it is.
  3. Confirm the call is not wrapped in enqueue_block_assets. Call it directly or from after_setup_theme.
  4. Confirm the registered block name matches the block name in rendered content.
  5. Set path to the absolute filesystem path so core can attach path data and check for an RTL file.

If the editor still does not show the styles after all five, the problem is likely outside this function. The render_block filter is documented as having no impact on editor behavior, so a stylesheet that depends on that filter for its front-end presence will not appear in the editor through that path. That is a structural property of the branch, not a missing argument.

Verification

Run these checks in order. Each one rules out a candidate before you move to the next.

Branch check. Log wp_should_load_block_assets_on_demand() on the editor request. If it returns true, the editor registration in the non-on-demand branch is not active on that call.

Handle check. Inspect $wp_styles->registered for your handle before your registration runs. If it exists, core or another plugin owns it.

Path data check. After your registration runs, confirm the style has path data attached:

wp eval '
  global $wp_styles;
  $h = "namespace-name-block-style";
  if ( isset( $wp_styles->registered[ $h ] ) ) {
    var_dump( $wp_styles->get_data( $h, "path" ) );
  }
'

If this returns false or null, path was not set or was not consumed. If it returns a string, path data is attached.

Editor reachability check. Confirm the enqueue_block_assets registration is reachable on the request that renders the editor. If the on-demand branch returned early, it is not.

Block name check. Confirm the block name passed to wp_enqueue_block_style() matches the blockName in rendered content. In the on-demand branch, a mismatch means the callback never fires for that block.

FAQ

Does path control whether the stylesheet loads in the editor?

No retrieved source states that. The documentation describes path as the absolute path to the stylesheet “so that it can potentially be inlined.” The source shows it feeding wp_style_add_data( $handle, 'path', $path ) and the RTL file swap. It is a data argument, not a loading switch.

Why does the front end work but the editor not?

The most likely structural reason is that the on-demand branch is active. In that branch, the callback is attached to render_block, which the Block Editor handbook documents as filtering front-end content and as having no impact on editor behavior. The editor registration in the non-on-demand branch is not reached.

Can I call wp_enqueue_block_style() without hooking it?

A user-contributed note states that the function can be called directly because it hooks enqueuing internally to enqueue_block_assets, and that hooking it to the same hook will not work. The contributed example uses after_setup_theme.

What does wp_should_load_block_assets_on_demand() check?

The reference page documents it as checking “whether block styles should be loaded only on-render.” The exact conditions are not stated on that page. Observe the return value on your own install rather than assuming.

Why is my style not enqueued at all?

Check the handle first. A user-contributed note warns that a handle matching the namespace-name-style pattern core already uses will not be enqueued. Then check the block name: in the on-demand branch, a mismatch between the registered name and $block['blockName'] means the callback never fires.

Sources

Two sources referenced during research failed retrieval and are not cited: the wp_register_block_style_handle() reference page and Trac ticket 54337. Claims that would have depended on them are omitted.

Why Every Permalink 404s After a Database Import: The rewrite_rules Row, flush_rewrite_rules() Timing, and What WP_Rewrite Actually Regenerates

You import the database. The homepage loads. wp-admin loads. Query-string URLs like /?p=123 resolve. Every pretty permalink returns 404. The instinct is to blame the import, the host, or the theme. The actual failure is narrower: the rewrite_rules row in wp_options is a cached artifact of the environment that generated it, and the environment that generated it is not the environment you just imported into.

Symptom

The site is up. The database is intact. Posts exist, terms exist, menus render. But /2024/06/some-post/ returns 404 while /?p=123 returns the post. That split is diagnostic. Query-string URLs bypass the rewrite layer entirely; pretty permalinks depend on it. When one works and the other does not, the routing layer is the suspect, not the content layer.

If you have seen this before, you may have reached for flush_rewrite_rules() and watched it either fix the site or produce a new, subtler breakage. Both outcomes are explainable, and the difference is timing.

Trace: what the rewrite_rules row actually is

WP_Rewrite is the core class that manages the rewrite rules enabling pretty permalinks, and it generates those rules from values in the database. The generated result is stored as a serialized array in the rewrite_rules option. The $rules property holds the rewrite rules and is set when rewrite_rules() is called.

That row is not a configuration file. It is a cache. It was generated at a specific moment, under a specific set of registered rule sources, for a specific site URL and permalink structure. It contains the regex-to-query-var mappings that WP::parse_request() matches against. If the row describes a different site than the one now serving requests, the match fails and WordPress returns 404. The 404 is correct output for the rules that are loaded.

You can inspect the row directly:

SELECT option_value
FROM wp_options
WHERE option_name = 'rewrite_rules';

And the permalink structure it was generated against:

SELECT option_value
FROM wp_options
WHERE option_name = 'permalink_structure';

If permalink_structure is empty, pretty permalinks are not enabled and the rewrite layer is not the problem. If it is populated but rewrite_rules is empty, missing, or contains rules referencing a different host or path, you are looking at a stale cache.

The rewrite_rules option is autoloaded, so checking it does not incur an extra database call. That makes it cheap to inspect from a diagnostic plugin or a one-off script.

Why an import breaks it

The retrieved sources describe how rewrite rules are generated and flushed. They do not document what any particular import tool does with the rewrite_rules row. So the honest framing is this: a database import moves the row, and the row was generated somewhere else. Whether the import tool preserves it, drops it, or overwrites it depends on the tool. What is consistent is that the row travels with the data while the environment that produced it does not.

Three environment differences matter most:

  • Active plugin set. Custom post types, taxonomies, and custom rewrite rules registered by plugins contribute rules. If the source site had a plugin active that the destination does not, the cached row contains rules for post types that no longer exist. If the destination has plugins the source did not, the cached row is missing their rules.
  • Permalink structure. The permalink_structure option drives the shape of the generated rules. A different structure produces a different rule set.
  • Site URL and path. Rules are generated relative to the install root. A site moved from a subdirectory to a domain root, or vice versa, has a rule set that no longer matches incoming request paths.

None of these require the import tool to do anything wrong. The row is simply a snapshot of a different environment.

What WP_Rewrite actually regenerates

When you flush, WP_Rewrite does not read the existing rewrite_rules row and patch it. It discards the cached value and rebuilds from scratch. The inputs are:

  • The permalink_structure option.
  • Registered post types and their rewrite arguments.
  • Registered taxonomies and their rewrite arguments.
  • Registered permastructs added via add_permastruct().
  • Rules added via add_rewrite_rule().
  • Endpoints added via add_rewrite_endpoint().
  • Filters on the generation process, including rewrite_rules_array, post_rewrite_rules, page_rewrite_rules, date_rewrite_rules, search_rewrite_rules, comments_rewrite_rules, author_rewrite_rules, root_rewrite_rules, and {$permastruct}_rewrite_rules.

The generate_rewrite_rules action fires after all rules have been created, and receives a reference to the entire $wp_rewrite object. That is the last point at which a plugin can influence the generated set before it is saved.

This is why flushing is not a repair operation. It is a regeneration operation. It reads whatever is registered at the moment it runs. If a custom post type has not yet registered, its rules will not be in the output. The resulting row will look complete — it will be a valid serialized array with plausible rules — but it will be missing entries. The site will 404 on the missing post type’s permalinks while everything else works.

The timing trap

Rewrite rules are usually registered inside the init hook. The init hook fires after WordPress has finished loading but before any headers are sent; most of WP is loaded and the user is authenticated at that stage. Plugins that register custom post types typically hook their registration to init as well.

That creates an ordering problem. If you call flush_rewrite_rules() before init has run — on a plugin activation hook, for example — the custom post types registered on init have not yet registered. The flush generates rules without them. The documentation for flush_rewrite_rules() states the problem directly: it not only removes the old rewrite rules but also creates new ones based on the post types and rewrite rule filters registered at that point in time, and calling it at an awkward moment where not everything is in place will generate wrong or incomplete rewrite rules.

Core itself acknowledges this. WP_Rewrite::refresh_rewrite_rules() postpones saving fresh rules to the database if the wp_loaded action has not yet occurred, because the rules may be partial and all rules need a chance to register. The source comment is explicit: it is not safe to save the results right now, as the rules may be partial, and all rules need the chance to register.

That is the reference pattern. Any manual recovery procedure should respect the same ordering constraint rather than trying to force a flush at an arbitrary point.

Hard flush vs. soft flush

flush_rewrite_rules() removes rewrite rules and then recreates them. Its $hard parameter controls whether .htaccess is updated (hard flush) or only the rewrite_rules option is updated (soft flush). The default is true — a hard flush.

The distinction matters on hosts where .htaccess is not writable. A hard flush attempts to write server-level rules. If that write fails silently, the option may be correct while the server-level rules remain stale. The site continues to 404 because the request never reaches WordPress’s rewrite layer in the first place. Checking whether .htaccess was actually written is a high-value diagnostic step after any hard flush.

A soft flush — flush_rewrite_rules(false) — updates only the option. It is appropriate when you know the server-level rules are already correct and you only need to regenerate the WordPress-side mapping. It is not a substitute for a hard flush when the server rules are stale.

A safe recovery sequence

The goal is to regenerate the rewrite_rules row from a context where every rule source has registered. That means after init, and ideally after wp_loaded.

Step one: verify the permalink structure is intact.

SELECT option_value
FROM wp_options
WHERE option_name = 'permalink_structure';

If it is empty, set it before doing anything else. Flushing against an empty structure produces a rule set that does not include pretty permalinks.

Step two: verify the active plugin set matches what the site expects. A missing plugin means missing rules. A newly added plugin means rules that were never generated. Either way, the flush needs to happen after the plugin set is settled.

Step three: trigger regeneration from a context where all rule sources are registered. The simplest reliable path is the admin UI: go to Settings → Permalinks and click Save Changes without making changes. The documentation for add_rewrite_rule() describes this as the standard way to flush and regenerate the rewrite rules database after modifying rules.

If you need to do it programmatically, hook late enough that init has completed. A one-time flag prevents repeated flushes:

add_action( 'wp_loaded', function() {
    if ( get_option( 'my_rewrite_flush_needed' ) ) {
        flush_rewrite_rules( false );
        delete_option( 'my_rewrite_flush_needed' );
    }
} );

Set my_rewrite_flush_needed to 1 before the request that should perform the flush. The wp_loaded hook fires after init, so all rule sources registered on init are present. The false argument performs a soft flush; use true only if you also need to rewrite .htaccess and you have confirmed it is writable.

An alternative that avoids the timing problem entirely is to delete the rewrite_rules option and let WordPress regenerate it at the right time. The documentation for flush_rewrite_rules() presents this as a way to force rewrite rules to be recreated at the right time, and notes that it avoids the problem of generating incomplete rules from an awkward hook. The trade-off is that regeneration happens on the next request that needs the rules, which may be a front-end request rather than an admin one. For a self-maintained install, that is usually acceptable.

DELETE FROM wp_options WHERE option_name = 'rewrite_rules';

Or, from WP-CLI:

wp option delete rewrite_rules

WordPress will regenerate the option on the next request. Because the option is autoloaded, the regeneration cost is paid once and the result is cached.

Verification

After flushing, confirm the row actually changed. Capture the value before and after:

SELECT MD5(option_value) AS rules_hash
FROM wp_options
WHERE option_name = 'rewrite_rules';

If the hash is identical before and after, the flush did not regenerate anything. That usually means the flush ran before the rule sources registered, or a filter on rewrite_rules_array is returning an unchanged or empty array.

Check the rule count:

SELECT LENGTH(option_value) AS rules_length
FROM wp_options
WHERE option_name = 'rewrite_rules';

A length of zero or a very small number indicates an empty or near-empty rule set. A populated site with custom post types should have a substantially larger value.

If you performed a hard flush, confirm .htaccess was written. The file should contain the standard WordPress block between # BEGIN WordPress and # END WordPress. If it does not, the write failed and the server-level rules are stale regardless of what the option contains.

Finally, test a permalink that exercises a custom post type or taxonomy, not just a standard post. A flush that ran too early will produce a rule set that handles standard posts correctly while missing custom rules. Testing only a standard post will give a false positive.

What not to do

Do not call flush_rewrite_rules() on every init or every page load as a workaround. The documentation for flush_rewrite_rules() states that it is an expensive operation and should only be used when necessary. Running it on every request pays that cost continuously and masks the registration-order problem that caused the failure. The symptom disappears; the underlying condition does not.

Do not call it on a plugin activation hook before the plugin’s own post types are registered. The documentation’s user-contributed notes describe this exact failure: the activation hook runs before the main plugin code that registers custom post types, so a flush at that point generates rules without them. The recommended pattern is to register the post types first, then flush, or to defer the flush to a later hook.

Do not assume a hard flush succeeded because it did not throw an error. On hosts where .htaccess is not writable, the write can fail without a visible error. Verify the file contents.

FAQ

Does a database import always leave the rewrite_rules row stale?

No. The retrieved sources do not document import tool behavior, so no universal claim is possible. What is consistent is that the row is generated from the environment that produced it. If the destination environment differs in active plugins, permalink structure, or site URL, the row will not match. If the destination is identical, it may still be valid.

Can I just delete the rewrite_rules option instead of flushing?

Yes, and it is often the better move during an import or activation sequence. Deleting the option defers regeneration to a point where the full rule set is available. The documentation presents this as an alternative to calling flush_rewrite_rules() at an awkward moment. It is not universally safer — it depends on when the next request that needs the rules occurs — but it avoids the incomplete-generation problem.

Why does the homepage still work when every permalink 404s?

The homepage is typically served by the root rewrite rule or by index.php directly. It does not depend on the post permalink rules. Query-string URLs like /?p=123 bypass the rewrite layer entirely. The 404s are specific to the rules that are missing or mismatched.

What if the rewrite_rules row looks correct but permalinks still 404?

Check .htaccess. If the server-level rules are stale or missing, the request never reaches WordPress’s rewrite layer. A hard flush should rewrite .htaccess, but if the file is not writable, the write fails silently. Verify the file contains the WordPress block.

Is flush_rewrite_rules() safe to call on every init?

It is technically possible but documented as an expensive operation that should only be used when necessary. Calling it on every request masks the registration-order problem and pays the regeneration cost continuously. The core’s own refresh_rewrite_rules() postpones saving until wp_loaded precisely because early saves can be partial.

Related reading

If you are debugging a fresh install where the symptom is “nothing found” rather than 404s on existing content, the failure mode is different and the diagnostic path is different. See What to Fix First When a New WordPress Site Says Nothing Found for that case.

Sources

Why wp_get_post_terms() Fires One Query Per Post in Your Archive Loop

Your category archive loads in 1.8 seconds. Query Monitor shows 200 queries, most of them identical SELECT statements against wp_term_relationships and wp_term_taxonomy. You assume a plugin is at fault. It is not. The template is calling the one term function that never reads the cache WordPress already primed for you.

Symptom

An archive loop that renders a category label per post produces a query count that scales linearly with posts_per_page. Set the archive to 30 posts and you get roughly 30 extra term queries. Set it to 100 and you get roughly 100. The queries are near-identical, differing only in the object_id passed to wp_term_relationships.

Query Monitor’s query list is the fastest confirmation. Look for repeated SELECT statements joining wp_term_relationships, wp_term_taxonomy, and wp_terms, each with a different object_id IN (...) clause. If the count matches your loop length, you have an N+1 introduced by the template, not by WP_Query.

A quick check without Query Monitor: enable SAVEQUERIES in wp-config.php and dump the log at the end of the request.

define( 'SAVEQUERIES', true );

// In a mu-plugin or template footer during debugging:
add_action( 'shutdown', function () {
    global $wpdb;
    $term_queries = array_filter( $wpdb->queries, function ( $q ) {
        return str_contains( $q[0], 'wp_term_relationships' );
    } );
    error_log( 'term queries: ' . count( $term_queries ) );
} );

If that number is close to your loop length plus one, the diagnosis is settled.

Trace

Two functions look interchangeable and are not. The first is wp_get_post_terms(). Its entire body is a wrapper:

function wp_get_post_terms( $post_id = 0, $taxonomy = 'post_tag', $args = array() ) {
    $post_id = (int) $post_id;
    $defaults = array( 'fields' => 'all' );
    $args = wp_parse_args( $args, $defaults );
    $tags = wp_get_object_terms( $post_id, $taxonomy, $args );
    return $tags;
}

That is the whole function, per the WordPress developer reference. It parses defaults and delegates to wp_get_object_terms(). It does not consult the object cache at any point.

The second is get_the_terms(). Its body reads the cache first:

$terms = get_object_term_cache( $post->ID, $taxonomy );
if ( false === $terms ) {
    $terms = wp_get_object_terms( $post->ID, $taxonomy );
    if ( ! is_wp_error( $terms ) ) {
        $term_ids = wp_list_pluck( $terms, 'term_id' );
        wp_cache_add( $post->ID, $term_ids, $taxonomy . '_relationships' );
    }
}

That is the source from the get_the_terms() reference page. On a cache hit it returns immediately. On a miss it queries once and writes the result back into the {taxonomy}_relationships cache group.

The wp_get_object_terms() reference page states the consequence plainly: “the results from wp_get_object_terms are not cached which will result in a db call everytime this function is called. For performance, functions like get_the_terms() (which the results of has been cached), should be used.”

So the two paths diverge at the first line. wp_get_post_terms() goes straight to the database. get_the_terms() checks the cache group first.

What WP_Query Already Did

Before your template runs, WP_Query has already primed the term cache for the posts in the loop. The function responsible is update_object_term_cache(), which is called by update_post_caches() and _prime_post_caches() according to the function reference.

Here is what it does. First, it checks the cache group per taxonomy for every post ID in the batch:

$taxonomies = get_object_taxonomies( $object_type );
foreach ( $taxonomies as $taxonomy ) {
    $cache_values = wp_cache_get_multiple( (array) $object_ids, "{$taxonomy}_relationships" );
    foreach ( $cache_values as $id => $value ) {
        if ( false === $value ) {
            $non_cached_ids[] = $id;
        }
    }
}

Only the IDs that miss the cache are collected. Then a single batched query runs for all of them:

$terms = wp_get_object_terms( $non_cached_ids, $taxonomies, array(
    'fields' => 'all_with_object_id',
    'orderby' => 'name',
    'update_term_meta_cache' => false,
) );

One query for the whole loop. The results are assembled per taxonomy and written back with wp_cache_add_multiple():

foreach ( $cache_values as $taxonomy => $data ) {
    wp_cache_add_multiple( $data, "{$taxonomy}_relationships" );
}

By the time your template’s while ( have_posts() ) loop starts, the {taxonomy}_relationships cache group already holds the term IDs for every post in the query. The cache is warm. The question is whether your template reads it.

Root Cause

The template calls wp_get_post_terms() inside the loop. That function never touches {taxonomy}_relationships. It goes directly to wp_get_object_terms(), which runs a fresh query for each post. The primed cache sits unused.

The N+1 is not in WP_Query. It is in the template. WP_Query did its job: one batched query, one cache write. The template then bypassed the cache entirely, once per iteration.

This is the diagnostic tell when reading core source: a function that reads {taxonomy}_relationships participates in the loop-priming contract. A function that goes straight to wp_get_object_terms() does not. The cache group name in the source is the signal.

Two clarifications that matter for the fix.

First, wp_get_post_terms() is not a bug. It exists because get_the_terms() does not accept the third $args parameter, as the wp_get_post_terms() reference page notes. When you need fields => 'ids' or fields => 'names', wp_get_post_terms() is the correct function. The problem is placement, not existence.

Second, a persistent object cache does not fix this. The {taxonomy}_relationships group is populated per request by update_object_term_cache(). The template’s uncached call issues its own query regardless of whether Redis or Memcached is present. Adding a persistent backend to a template that calls wp_get_post_terms() in a loop changes nothing about the query count.

Fix

Replace wp_get_post_terms() with get_the_terms() inside the loop. The signature is different: get_the_terms( $post, $taxonomy ) takes two arguments, not three.

Before:

while ( have_posts() ) {
    the_post();
    $terms = wp_get_post_terms( get_the_ID(), 'category' );
    foreach ( $terms as $term ) {
        echo esc_html( $term->name );
    }
}

After:

while ( have_posts() ) {
    the_post();
    $terms = get_the_terms( get_the_ID(), 'category' );
    if ( $terms && ! is_wp_error( $terms ) ) {
        foreach ( $terms as $term ) {
            echo esc_html( $term->name );
        }
    }
}

The second version reads the primed cache and issues zero additional queries for posts already primed by WP_Query.

If you need fields => 'ids' or fields => 'names', you cannot use get_the_terms(). In that case, hoist the call out of the loop and batch it. Collect the post IDs first, then call wp_get_object_terms() once with the full array:

$post_ids = wp_list_pluck( $query->posts, 'ID' );
$all_terms = wp_get_object_terms( $post_ids, 'category', array(
    'fields' => 'all_with_object_id',
) );

// Group by object_id for use in the loop.
$terms_by_post = array();
foreach ( $all_terms as $term ) {
    $terms_by_post[ $term->object_id ][] = $term;
}

One query for the whole loop instead of one per post. The all_with_object_id field is what makes the grouping possible; it is documented in the WP_Term_Query reference as returning term objects with the object_id property populated.

Verification

Query Monitor is the fastest check. The WP_Object_Cache reference lists it alongside Debug Bar as a tool for analyzing the object cache. Load the archive before the fix and note the count of queries against wp_term_relationships. Load it after and note the count again.

What you should see: the per-post term queries disappear. One priming query remains, issued by update_object_term_cache() before the loop. The total query count drops by roughly the loop length.

If you want to confirm the cache is being read rather than just assuming, check the cache hit counter. The WP_Object_Cache class exposes $cache_hits and $cache_misses as public properties. A quick mu-plugin can log them:

add_action( 'shutdown', function () {
    global $wp_object_cache;
    error_log( sprintf(
        'cache hits: %d, misses: %d',
        $wp_object_cache->cache_hits,
        $wp_object_cache->cache_misses
    ) );
} );

Before the fix, the miss count will be high because every wp_get_post_terms() call bypasses the cache entirely. After the fix, the hits should increase and the misses should drop.

For a more targeted check, inspect the {taxonomy}_relationships group directly. The cache key is the post ID, and the value is an array of term IDs:

$term_ids = wp_cache_get( get_the_ID(), 'category_relationships' );
var_dump( $term_ids ); // array of ints, or false on miss

If that returns an array of term IDs inside the loop, the cache is primed and get_the_terms() will read it. If it returns false, something upstream prevented priming, and you should check whether the query has update_post_term_cache set to false or whether the taxonomy is registered in a way that excludes it from the default priming path.

Why This Keeps Happening

The two functions have nearly identical names and nearly identical signatures. wp_get_post_terms( $post_id, $taxonomy, $args ) and get_the_terms( $post, $taxonomy ) differ by one argument and one prefix. The prefix is the tell: wp_get_* functions in WordPress tend to be lower-level and uncached; get_the_* functions tend to be template-facing and cached. That is not a universal rule, but it holds here.

The deeper issue is that the cache group name is not visible from the calling code. You have to read the source of both functions to see that one reads {taxonomy}_relationships and the other does not. The function reference pages do state it, but the distinction is buried in user-contributed notes rather than in the function signatures.

The general rule for this class of failure: when a core function has a cached sibling, the uncached one is a deliberate escape hatch for arguments the cached one cannot express. Using it inside a loop is the bug. The cache group name in the source is the tell.

FAQ

Does this affect single post templates too?

On a single post, wp_get_post_terms() issues one query. That is not an N+1. The failure mode is specific to loops where the same call repeats per iteration. If your single template calls it once, the cost is negligible.

Will a persistent object cache fix this?

No. The {taxonomy}_relationships group is populated per request by update_object_term_cache(). The template’s uncached call issues its own query regardless of whether a persistent backend is present. The fix is the function swap, not the cache backend.

What about the ‘exponential’ warning in the update_object_term_cache() docs?

That warning is about the priming call itself, not about the template’s per-post calls. The reference page states: “Processing time can increase exponentially depending on both the number of passed term IDs and the number of taxonomies those terms belong to.” That is a caution about calling update_object_term_cache() with large ID sets. The template’s N+1 is linear in post count. Conflating the two leads teams to optimize the wrong function.

Can I just remove wp_get_post_terms() from the codebase?

No. It is the correct function when you need $args that get_the_terms() cannot express, particularly fields. The rule is placement: uncached calls belong outside the loop or batched, not per-iteration.

How do I know if the cache was primed for my query?

Check wp_cache_get( $post_id, $taxonomy . '_relationships' ) inside the loop. If it returns an array of term IDs, the cache is primed. If it returns false, check whether the query has update_post_term_cache set to false or whether the taxonomy is excluded from the default priming path. The WP_Term_Query reference documents cache_results and update_term_meta_cache as separate parameters, both defaulting to true.

Why update_option() Returns false Even When Nothing Broke: The Unchanged-Value Short-Circuit, maybe_serialize, and the Callers That Misread the Return

Your settings-save handler logs update_option() failed and shows an admin notice. The option is intact. The database is fine. The editor clicks Save again, gets the same notice, and starts distrusting the screen. Nothing is broken except the caller’s reading of the return value.

update_option() returns false on three distinct paths, and only one of them is an error. The other two are normal outcomes that the function’s own documentation flattens into a single boolean. If your code treats false as “write failed,” you will misreport no-op saves, and you will misreport the add_option() fallback when it declines to add an existing option.

The return contract is narrower than it looks

The developer reference states the return plainly: “True if the value was updated, false otherwise.” That is the whole contract. It does not say false means error. It says false means the value was not updated — which includes the case where the value did not need updating.

Contrast that with wpdb::update(), which returns int|false: the number of rows updated, or false on error. The user-contributed note on that page is explicit: “a return value of 0 doesn’t indicate failure. Nor does it necessarily mean that no matching rows were found: it may be that there were matching rows, but the data didn’t need to be updated because they matched the data already.” To check only for database errors, the note says, use strict comparison: if ( false === $updated ).

update_option() sits on top of that and collapses the distinction. It returns false when $wpdb->update() returns 0 (no rows changed) and when it returns false (error). It also returns false before any query runs, when the new value equals the old one. Three paths, one boolean.

Path 1: the unchanged-value short-circuit

The relevant lines in wp-includes/option.php are quoted in the reference source:

if ( $value === $old_value || maybe_serialize( $value ) === maybe_serialize( $old_value ) ) {
    return false;
}

This runs after sanitize_option() and after the pre_update_option_{$option} and pre_update_option filters. So the comparison is between the sanitized, filtered new value and the value currently stored. If they match, the function returns false and never touches the database.

The comment above the check in core says: “If the new and old values are the same, no need to update.” That is the entire intent. It is a short-circuit, not a failure.

Why the maybe_serialize() fallback? Because $value === $old_value is an identity comparison. Two arrays with the same keys and values are equal under == but not identical under === unless they are the same instance. Two objects can be equal in content and still fail ===. The serialized form gives a stable string comparison that catches content-equivalent arrays and objects that identity comparison misses. The reference source comment frames it as avoiding unnecessary database calls for otherwise identical object instances.

That fallback has a sharp edge. maybe_serialize() serializes arrays and objects, returns scalars unchanged, and — per the reference — “strings that contain already serialized values are serialized again, resulting in a nested serialization.” If your option value is a string that happens to look like a serialized array, maybe_serialize() will wrap it again. The comparison still works as long as both sides go through the same function, but the stored representation changes. That is a separate failure mode; for the return-value question, the point is that the comparison is content-based, not identity-based, and it can return false for a value that is semantically identical to what is stored.

Path 2: the add_option() delegation

If the short-circuit does not fire, update_option() checks whether the old value equals the default for that option:

if ( apply_filters( "default_option_{$option}", false, $option, false ) === $old_value ) {
    return add_option( $option, $value, '', $autoload );
}

When that condition is true, update_option() delegates to add_option() and returns whatever add_option() returns. add_option() returns true if the option was added and false otherwise. And its documentation says: “Existing options will not be updated.”

So there is a path where update_option() returns false because add_option() refused to add an option that already exists. The option is present, the value may be unchanged, and the caller sees false. This is not a database error. It is the function correctly declining to insert a duplicate row.

This branch is easy to miss because it only triggers when the stored value matches the default_option_{$option} filter result. For options that were never explicitly set, or that were set to the same value the filter returns, the delegation is live. If you have a custom default_option_{$option} filter that returns the same value as what is stored, you can land here on every save.

Path 3: the actual database error

The third path is the one callers assume is the only one:

$result = $wpdb->update( $wpdb->options, $update_args, array( 'option_name' => $option ) );
if ( ! $result ) {
    return false;
}

$wpdb->update() returns the number of rows updated, or false on error. The ! $result check is loose, so it catches both 0 and false. A 0 here means the row existed but the value written was identical to what was already there — which, given the short-circuit above, should be rare, but can happen if another process wrote the same value between the get_option() read and the UPDATE. A false here means the query itself failed: connection loss, table corruption, permission problem, or a malformed value that $wpdb rejected.

Only this third path is a write failure. The first two are normal control flow.

What the hooks tell you

The updated_option action fires “after the value of an option has been successfully updated.” It does not fire on the unchanged-value short-circuit, and it does not fire when add_option() declines to add. The update_option action fires immediately before the database write, so it also does not fire on the short-circuit.

This means a save handler that keys its success message off updated_option will stay silent on a no-op save. That is correct behavior. The option did not change, so no update occurred, so no update hook fired. If your UI interprets that silence as failure, the UI is wrong, not the hook.

If you need to distinguish “no change” from “error” in your own code, you cannot rely on the return value alone. You need to check the stored value yourself.

A decision procedure that does not guess

Before calling update_option(), or after a false return, read the current value and compare it to the intended value through the same serialization function core uses:

$intended = $new_value;
$stored   = get_option( 'my_option' );

if ( maybe_serialize( $intended ) === maybe_serialize( $stored ) ) {
    // No change needed. This is not an error.
    return 'unchanged';
}

$result = update_option( 'my_option', $intended );

if ( false === $result ) {
    // Either add_option() declined, or wpdb::update() failed.
    // Re-read to see whether the value landed anyway.
    $after = get_option( 'my_option' );
    if ( maybe_serialize( $intended ) === maybe_serialize( $after ) ) {
        return 'unchanged';
    }
    return 'error';
}

return 'updated';

This is not elegant, but it is honest. It separates the three outcomes that update_option() merges into one boolean. The re-read after a false return catches the case where add_option() declined because the option already held the intended value, and the case where a concurrent write landed the same value.

If you want to know whether the option existed before the call, check get_option() with a sentinel default:

$sentinel = new stdClass();
$before   = get_option( 'my_option', $sentinel );
$existed  = ( $before !== $sentinel );

That tells you whether you are on the update path or the add path. It does not change the return value, but it tells you which branch of update_option() you are likely to hit.

Verification: what to look at when you are not sure

WP-CLI gives you the stored value without loading the admin:

wp option get my_option --format=json

To see the raw stored representation, including serialization:

wp db query "SELECT option_value FROM $(wp db prefix)options WHERE option_name = 'my_option'"

To check whether the option is autoloaded and how large it is:

wp db query "SELECT option_name, autoload, LENGTH(option_value) AS bytes FROM $(wp db prefix)options WHERE option_name = 'my_option'"

Query Monitor will show you the get_option() call and any UPDATE query that follows. If you see the get_option() but no UPDATE, the short-circuit fired. If you see an UPDATE that affects zero rows, you are on the wpdb::update() zero-row path. If you see no UPDATE and no get_option() for that option, the value was already in the alloptions cache and the comparison happened in memory.

For a quick check of whether the value you intend to write is already there, without writing:

wp eval '
$intended = array( "foo" => "bar" );
$stored   = get_option( "my_option" );
var_dump( maybe_serialize( $intended ) === maybe_serialize( $stored ) );
'

That is the same comparison core performs. If it prints true, update_option() will return false and no query will run.

Why this matters for editorial workflow plumbing

A save handler that reports failure on a no-op update trains editors to distrust the UI. They click Save, see an error, click Save again, see the same error, and conclude the settings screen is broken. The option is fine. The handler is misreading a boolean that was never designed to carry three states.

The fix is not to suppress the error. It is to make the handler report three outcomes: updated, unchanged, and error. The unchanged case is not a failure; it is the system telling you the value you asked for is already the value that is stored. That is a success condition for the editor’s intent, even though it is a false return from the function.

If you are building settings screens on top of the Settings API, the same logic applies. The Settings API calls update_option() under the hood. A false return there does not mean the settings form is broken; it means the submitted value matched the stored value. The API does not surface that distinction, so your own save handler has to.

For a related failure mode in a different part of the stack — a new site that says “Nothing Found” because the query is correct and the content is not — see What to Fix First When a New WordPress Site Says Nothing Found. The pattern is the same: the system is working as designed, and the caller is reading the signal wrong.

FAQ

Does update_option() return false when the value is unchanged?
Yes. The source returns false before any database write when $value === $old_value or when their maybe_serialize() forms match.

Does updated_option fire when the value is unchanged?
No. It fires after a successful update. The unchanged-value path returns before the update, so the hook does not run.

Can update_option() return false even though the option exists and the value is correct?
Yes. That is the unchanged-value short-circuit, and it is the intended behavior.

What does maybe_serialize() do to a string that already looks serialized?
It serializes it again, producing nested serialization. The reference documentation notes this explicitly.

How do I tell a real database error from a no-op?
Re-read the option after the false return. If the stored value matches the intended value, it was a no-op. If it does not, and $wpdb->last_error is non-empty, it was a write failure.

Is wpdb::update() returning 0 an error?
No. The documentation says a return of 0 does not indicate failure; it may mean matching rows already held the same data. Only false indicates an error.

Why wp_mail() Returns true and Nothing Ever Arrives: The PHPMailer Handoff, the wp_mail_failed Action Nobody Hooks, and the Password Reset That Depends on It

The password reset form says the email is on its way. The user checks the inbox, the spam folder, the promotions tab, and then opens a support ticket. You check the debug log. Nothing. You check the database. The reset key exists. You check wp_mail() in a scratch plugin and it returns true. The mail server logs show no connection attempt. This is not a mystery. It is a contract you have not read literally enough.

Symptom: success on screen, silence in the inbox

The failure mode is consistent enough to describe as a pattern. A user requests a password reset. WordPress renders the confirmation screen. No email arrives. If you instrument the reset path, retrieve_password() returns true. If you call wp_mail() directly with the same arguments, it also returns true. The site reports no error because, from WordPress’s perspective, no error occurred.

The reason is in the function’s own documentation, which is unusually blunt about this:

A true return value does not automatically mean that the user received the email successfully. It just only means that the method used was able to process the request without any errors.

That sentence is the whole article compressed into two lines. The return value is a request-processing signal, not a delivery receipt. Everything below is the trace of why that distinction matters in production, and what to instrument before you reach for a plugin.

Trace: what wp_mail() actually does before it returns

Read the source. The function is in wp-includes/pluggable.php, and the shape of it is worth walking through because the return value is determined at a specific point in the sequence, not at the end of the send.

First, wp_mail() compacts its arguments and runs them through the wp_mail filter. Then it checks pre_wp_mail. If any callback returns a non-null value from that filter, wp_mail() returns that value immediately and never touches PHPMailer. This is the first place a true can come from without a single byte leaving the server.

$pre_wp_mail = apply_filters( 'pre_wp_mail', null, $atts );
if ( null !== $pre_wp_mail ) {
    return $pre_wp_mail;
}

If nothing short-circuits, the function reaches for the global $phpmailer. If the global is not already an instance of PHPMailer\PHPMailer\PHPMailer, it loads the bundled PHPMailer files and instantiates WP_PHPMailer. That instance is reused across calls in the same request, which is why the function clears recipients, attachments, custom headers, reply-tos, body, and alt-body before configuring the next message.

Then it sets the From address inside a try/catch:

try {
    $phpmailer->setFrom( $from_email, $from_name, false );
} catch ( PHPMailer\PHPMailer\Exception $e ) {
    $mail_error_data = compact( 'to', 'subject', 'message', 'headers', 'attachments' );
    $mail_error_data['phpmailer_exception_code'] = $e->getCode();
    do_action( 'wp_mail_failed', new WP_Error( 'wp_mail_failed', $e->getMessage(), $mail_error_data ) );
    return false;
}

This is the only place in the retrieved source where wp_mail_failed fires. It fires when setFrom() throws. It does not fire when the later send step fails, because the later send step is not inside this catch. That asymmetry is the root of the bug report.

After the From address is set, wp_mail() assigns Subject and Body, iterates the recipient headers, and then calls:

// Set to use PHP's mail().
$phpmailer->isMail();

That line is the default transport decision. Unless something on phpmailer_init changes it, the message is handed to PHP’s mail() function. The documentation for phpmailer_init states the relationship plainly: wp_mail() relies on the PHPMailer class to send email through PHP’s mail function, and the hook lets you pass your own arguments to the PHPMailer object.

Content type, charset, custom headers, attachments, and embeds are configured next. Then the function reaches the send step. The retrieved source excerpt ends before that step, but the structure is enough to state the important fact: the true return is produced by the send call’s result, and the send call’s result reflects whether PHPMailer could process the request, not whether a remote MTA accepted the message.

Root cause: the return value is not a delivery signal

Three things are true at once, and they explain the whole failure mode.

One. wp_mail() returns true when the method used processed the request without errors. With the default transport, that method is PHP’s mail(). On many hosts, mail() returns true when the message has been handed to the local MTA or sendmail binary, not when it has been delivered. If the local MTA then drops the message, bounces it, or routes it to a queue that never drains, wp_mail() has already returned true.

Two. wp_mail_failed fires from the setFrom() catch. It is not a general delivery-failure channel. If you have not hooked it, you have no log of the one failure class it does cover. If you have hooked it, you still do not get a signal for failures that occur after the From address is set.

Three. The password reset flow inherits this ambiguity directly. retrieve_password() calls wp_mail() and checks the return value:

if ( ! wp_mail( $to, $subject, $message, $headers ) ) {
    $errors->add( 'retrieve_password_email_failure', sprintf(
        __( 'Error: The email could not be sent. Your site may not be correctly configured to send emails. Get support for resetting your password.' ),
        esc_url( __( 'https://wordpress.org/documentation/article/reset-your-password/' ) )
    ) );
    return $errors;
}
return true;

When wp_mail() returns false, the user sees an error and the function returns a WP_Error. When wp_mail() returns true, retrieve_password() returns true and the user sees the confirmation screen. There is no third branch. There is no delivery confirmation. The reset key is in the database either way, because get_password_reset_key() runs before the mail call.

So the user gets a success screen, the key exists, and the email is somewhere between the PHP process and the recipient’s inbox. The site has no mechanism to tell you which.

Fix: instrument the handoff and the failure action

The fix is not a plugin. The fix is instrumentation that makes the invisible handoff visible. Two hooks do most of the work.

Hook wp_mail_failed and log the WP_Error

The hook documentation describes it as firing after a PHPMailer exception is caught, and the $error parameter as a WP_Error containing the PHPMailer exception message plus an array with recipient, subject, message, headers, attachments, and embeds. It was introduced in WordPress 4.4.0. A minimal logger looks like this:

add_action( 'wp_mail_failed', function ( WP_Error $error ) {
    $data = $error->get_error_data();
    error_log( sprintf(
        'wp_mail_failed: %s | to=%s | subject=%s | code=%s',
        $error->get_error_message(),
        is_array( $data['to'] ?? null ) ? implode( ',', $data['to'] ) : ( $data['to'] ?? '' ),
        $data['subject'] ?? '',
        $data['phpmailer_exception_code'] ?? ''
    ) );
} );

This will not catch every silent failure. It will catch the setFrom() class of failure, which is the one the core source actually dispatches. If your log stays empty while mail still disappears, you have learned something useful: the failure is downstream of the From address, and you need to look at the transport.

Inspect the transport on phpmailer_init

The phpmailer_init action fires after PHPMailer is initialized and passes the instance by reference. The hook page notes that it is initiated with do_action_ref_array rather than do_action, which matters if you write a callback that expects a single array argument. You still register it with add_action, but the callback receives the PHPMailer instance as its first parameter.

A diagnostic callback that logs the transport configuration without changing it:

add_action( 'phpmailer_init', function ( $phpmailer ) {
    error_log( sprintf(
        'phpmailer_init: Mailer=%s Host=%s Port=%s SMTPAuth=%s',
        $phpmailer->Mailer ?? 'unknown',
        $phpmailer->Host ?? '',
        $phpmailer->Port ?? '',
        isset( $phpmailer->SMTPAuth ) ? ( $phpmailer->SMTPAuth ? 'true' : 'false' ) : 'unset'
    ) );
} );

If Mailer is mail, you are on PHP’s mail() and the return value tells you almost nothing about delivery. If Mailer is smtp, you have a transport that can report connection and authentication failures, and the return value becomes more meaningful — though still not a delivery receipt.

The hook page’s own example shows the SMTP configuration pattern: call isSMTP(), set Host, SMTPAuth, Port, Username, Password, and optionally SMTPSecure. That is the documented way to move off PHP’s mail() without installing a plugin.

Watch the content-type filter

One more piece of shared state is worth flagging because it produces unrelated failures that look like this one. The wp_mail_content_type filter is global to the request. The documentation warns that failing to reset it back to text/plain after sending can lead to unexpected problems with emails from WordPress, plugins, and themes. If you set it to text/html for one message and do not restore it, subsequent messages in the same request inherit the HTML content type. That is a different bug, but it lands in the same inbox.

Verification: distinguish a false positive from a genuine send

You cannot verify delivery from inside WordPress with the default transport. You can verify the following, in order, and each step narrows the failure domain.

Step 1: Confirm the reset key exists. The key is created before wp_mail() is called, so its presence tells you the reset flow reached the mail step.

wp db query "SELECT user_login, user_activation_key FROM $(wp db prefix)users WHERE user_activation_key != '' ORDER BY user_registered DESC LIMIT 5;"

If the key is there and the email is not, the failure is in the mail path, not the reset logic.

Step 2: Confirm the transport. Use the phpmailer_init logger above. If Mailer is mail, you are relying on the host’s local mail configuration. The wp_mail() documentation notes that for the function to work, the SMTP and smtp_port settings (default: 25) need to be set in php.ini. That is a host-level dependency, not a WordPress one.

Step 3: Confirm the failure action is wired. If wp_mail_failed is not hooked, you have no log of the one failure class core dispatches. Hook it and re-run the reset. An empty log after a failed reset is itself evidence: the failure is downstream of setFrom().

Step 4: Check the host’s mail log. This is outside WordPress and outside the scope of the retrieved sources, but it is the only place that can tell you whether the local MTA accepted the message. If the MTA log shows no connection, the message never left PHP. If it shows a connection and a queue ID, the message left the server and the failure is downstream.

Step 5: Test with a known-good recipient. Send a test message to an address you control on a different domain. If it arrives, the transport works and the problem is filtering or routing. If it does not, the transport is the problem.

What this means for the password reset flow specifically

The reset flow is the highest-stakes consumer of wp_mail() because it is the only path a locked-out user has. The dependency is direct and documented: retrieve_password() returns true when finished and a WP_Error object on error, and the only mail-related error it adds is retrieve_password_email_failure, which is gated on wp_mail() returning false.

That means the user-facing behavior is binary in a way that does not match reality. Either the site says the email could not be sent, or it says nothing and the user waits. There is no state for “the site thinks it sent the email but the email did not arrive.” If you maintain a production install, that state is yours to instrument, because core does not have it.

The practical posture is to treat wp_mail()‘s return value as a request-processing signal and nothing more. Log the failure action. Log the transport. Verify the reset key. Check the host’s mail log. Only after those steps should you consider whether a plugin is warranted — and if you do, the plugin is replacing the transport, not fixing the return value.

FAQ

Does wp_mail_failed fire for every failed send?

No. In the retrieved source, it fires from the catch around setFrom(). Failures that occur after the From address is set are not dispatched through that catch. The hook documentation describes it as firing after a PHPMailer exception is caught, which is consistent with the source but does not extend it to the later send step.

Why does retrieve_password() return true when the email never arrives?

Because it only checks wp_mail()‘s return value. When wp_mail() returns true, retrieve_password() returns true. The return value reflects request processing, not delivery, so the reset flow inherits that ambiguity.

Is the default transport PHP’s mail()?

Yes, unless something changes it. The wp_mail() source calls $phpmailer->isMail(), and the phpmailer_init documentation states that wp_mail() relies on PHPMailer to send email through PHP’s mail function. The phpmailer_init hook is the documented place to switch to SMTP.

Why does my phpmailer_init callback not receive the PHPMailer instance?

Because the hook is initiated with do_action_ref_array rather than do_action. The hook documentation notes this distinction. You still register with add_action, but the callback receives the PHPMailer instance as its first parameter, not an array.

Can I verify delivery from inside WordPress?

Not with the default transport. You can verify that the reset key was created, that the transport is configured as expected, and that the failure action is wired. Delivery verification requires the host’s mail log or an external delivery service. The retrieved sources do not support any claim about specific delivery rates or host policies.

Sources

Why Quick Edit Bypasses Your save_post Logic: wp_ajax_inline_save, the $_POST Field Whitelist, and the Columns That Update While postmeta Silently Doesn’t

Symptom: An editor changes a post’s title, slug, or status in the Posts list table’s Quick Edit row. The list refreshes. The title changes. The slug changes. The status changes. And the custom field your theme reads on the front end — the one you wrote a tidy save_post callback to keep in sync — does not change. No error. No notice. The AJAX response is a fresh table row, not a JSON error, so nothing looks wrong.

This is not a bug in your callback. It is the expected behavior of a save path that never had your field in scope to begin with. Quick Edit is a narrow, whitelisted update. If you assume it behaves like the classic editor’s post.php submit, you will keep writing callbacks that fire and then do nothing useful.

Trace: what actually handles the Quick Edit request

Quick Edit posts to admin-ajax.php with action=inline-save. WordPress routes that to wp_ajax_inline_save(), defined in wp-admin/includes/ajax-actions.php. The function is short enough to read in one sitting, and reading it is the whole exercise.

The first thing it does is verify the nonce inlineeditnonce against $_POST['_inline_edit'], then check post_ID, then check the capability for the specific post. If a post lock exists, it prints a message and dies. Assuming those pass, it does this:

$data = &$_POST;
$post = get_post( $post_id, ARRAY_A );
$post = wp_slash( $post );
$data['content'] = $post['post_content'];
$data['excerpt'] = $post['post_excerpt'];
$data['user_ID'] = get_current_user_id();

Two lines matter more than the rest. Quick Edit does not send content or excerpt in its payload, so the handler backfills them from the database row it just fetched. That is deliberate: Quick Edit is not supposed to touch the body. It then renames post_parent to parent_id, resolves status from keep_private or _status, defaults comment_status and ping_status to closed when empty, and strips any taxonomy from tax_input whose show_in_quick_edit is false (filterable via quick_edit_show_taxonomy).

Then it calls edit_post() with no arguments. Per the function reference, edit_post() with a null $post_data uses the $_POST superglobal. So the entire Quick Edit save is $_POST, filtered through _wp_translate_postdata() and then _wp_get_allowed_postdata().

Root cause: the whitelist is the contract

_wp_get_allowed_postdata() returns only the fields WordPress considers post columns. Anything else in $_POST is discarded before wp_update_post() runs. This is the field whitelist the topic names, and it is why a custom field posted by a hand-rolled form will not survive the trip. Quick Edit’s own JS never sends your meta key anyway, so the whitelist is the second gate, not the first.

Inside edit_post(), meta is handled in exactly two places:

if ( isset( $post_data['meta'] ) && $post_data['meta'] ) {
    foreach ( $post_data['meta'] as $key => $value ) {
        $meta = get_post_meta_by_id( $key );
        if ( ! $meta ) { continue; }
        if ( (int) $meta->post_id !== $post_id ) { continue; }
        if ( is_protected_meta( $meta->meta_key, 'post' ) || ! current_user_can( 'edit_post_meta', $post_id, $meta->meta_key ) ) { continue; }
        if ( is_protected_meta( $value['key'], 'post' ) || ! current_user_can( 'edit_post_meta', $post_id, $value['key'] ) ) { continue; }
        update_meta( $key, $value['key'], $value['value'] );
    }
}
if ( isset( $post_data['deletemeta'] ) && $post_data['deletemeta'] ) {
    foreach ( $post_data['deletemeta'] as $key => $value ) {
        $meta = get_post_meta_by_id( $key );
        if ( ! $meta ) { continue; }
        if ( (int) $meta->post_id !== $post_id ) { continue; }
        if ( is_protected_meta( $meta->meta_key, 'post' ) || ! current_user_can( 'delete_post_meta', $post_id, $meta->meta_key ) ) { continue; }
        delete_meta( $key );
    }
}

Note the shape. meta is keyed by meta ID, not by meta key. Each entry carries a key and a value. It updates existing rows only — get_post_meta_by_id() returns false for a key that does not exist, and the loop continues. There is no insert path here. Quick Edit’s JS does not populate meta or deletemeta at all, so both branches are dead on arrival for a Quick Edit request.

Now the part that trips people: edit_post() ends with wp_update_post( $translated ), and wp_update_post() fires save_post. So save_post does run on Quick Edit. The hook is not bypassed. What is bypassed is the data your callback expects to find in $_POST.

This is the trap. A callback written like this:

add_action( 'save_post', function ( $post_id ) {
    if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) return;
    if ( ! isset( $_POST['my_meta_nonce'] ) ) return;
    if ( ! wp_verify_nonce( $_POST['my_meta_nonce'], 'my_meta' ) ) return;
    if ( ! current_user_can( 'edit_post', $post_id ) ) return;
    update_post_meta( $post_id, 'my_meta', sanitize_text_field( $_POST['my_meta'] ) );
} );

returns early on every Quick Edit save, because my_meta_nonce is not in the payload. The post row updates. The meta does not. The editor sees a successful save. You see a stale value on the front end two hours later.

The save_post documentation is explicit that the data lives in $_POST, $_GET, or the global $post_data depending on the edit path, and that quick edits use $_POST. It does not promise that $_POST contains your fields. That is the gap.

What Quick Edit actually updates

From the handler and the JS that drives it, the columns in play are the ones the list table renders inline: post_title, post_name (slug), post_status via _status or keep_private, post_author, post_parent (renamed to parent_id), comment_status, ping_status, post_password via visibility, sticky, menu_order, post_date, and tax_input for taxonomies that opt in. Content and excerpt are preserved from the database, not edited.

Everything else — every custom field, every term in a taxonomy with show_in_quick_edit false, every piece of postmeta your theme or plugin owns — is untouched. Not deleted. Not zeroed. Untouched. The row keeps whatever value it had before the Quick Edit, which is why the failure is silent: the old value is still there, still valid, just no longer correct.

Fix: stop reading $_POST, start reading the post

The durable pattern is to make your save_post callback idempotent and derive its inputs from the post object and the request context, not from a nonce that only exists on one save path. Two approaches work.

Approach one: recompute from post columns. If your meta is a function of the post’s own fields — a reading-time estimate from post_content, a slug-derived key, a status flag — compute it on every save_post and write it. Quick Edit changes the title, your callback recomputes, the meta stays consistent. No nonce needed, because you are not trusting user input; you are deriving from data WordPress already validated.

add_action( 'save_post_post', function ( $post_id, $post, $update ) {
    if ( wp_is_post_revision( $post_id ) ) return;
    if ( 'publish' !== $post->post_status ) return;
    $words = str_word_count( wp_strip_all_tags( $post->post_content ) );
    update_post_meta( $post_id, '_reading_minutes', max( 1, (int) ceil( $words / 220 ) ) );
}, 10, 3 );

This runs on Quick Edit, classic editor, block editor, WP-CLI, and REST, because all of them end in wp_update_post() and all of them fire save_post. The save_post_{post_type} variant is preferred when you only care about one post type; the docs note it fires before the generic save_post, and that plugins hooking the generic action will still run.

Approach two: gate on the fields you actually need, not on a nonce. If your meta comes from a form field that only exists in the classic editor, check for that field specifically and bail if it is absent — but do not treat absence as failure. Absence means “this save path does not own this field,” which is correct for Quick Edit.

add_action( 'save_post_post', function ( $post_id, $post, $update ) {
    if ( wp_is_post_revision( $post_id ) ) return;
    if ( ! isset( $_POST['my_meta'] ) ) return; // Quick Edit: leave existing value alone.
    if ( ! isset( $_POST['my_meta_nonce'] ) || ! wp_verify_nonce( $_POST['my_meta_nonce'], 'my_meta' ) ) return;
    if ( ! current_user_can( 'edit_post', $post_id ) ) return;
    update_post_meta( $post_id, 'my_meta', sanitize_text_field( wp_unslash( $_POST['my_meta'] ) ) );
}, 10, 3 );

The difference from the broken version is the order: check for the field first, nonce second. The broken version checks the nonce first and bails, which is indistinguishable from “field not present” but reads as “security failure” in your head. Reordering makes the intent legible and the behavior correct.

If you need Quick Edit to actually edit your meta, you have to add it to the Quick Edit form yourself. That means hooking quick_edit_custom_box to render the input, enqueueing JS that copies the row’s current value into the form on open, and extending the AJAX payload. The handler will pass unknown fields through $_POST to edit_post(), but _wp_get_allowed_postdata() will drop them before wp_update_post(), so you must read them in your own save_post callback and write the meta yourself. This is the documented extension point; it is not a supported way to smuggle arbitrary fields into the post row.

Verification

Do not trust the list table refresh. Verify at the database and at the hook.

Check the meta value directly. Before the Quick Edit, note the value:

wp post meta get 1234 my_meta

Run the Quick Edit, changing only the title. Then:

wp post meta get 1234 my_meta
wp post get 1234 --field=post_title

If the title changed and the meta did not, and your callback is supposed to derive the meta from the title, the callback is not running or is bailing. If the meta did change, the callback ran and did its job.

Confirm save_post fires. Drop a temporary logger into wp-content/mu-plugins/:

add_action( 'save_post', function ( $post_id, $post, $update ) {
    error_log( sprintf( 'save_post %d %s update=%s', $post_id, $post->post_type, $update ? 'yes' : 'no' ) );
}, 10, 3 );

Trigger a Quick Edit and watch wp-content/debug.log with WP_DEBUG_LOG enabled. You will see the line. That confirms the hook is not the problem; the payload is.

Inspect the actual request. Open DevTools, Network tab, filter for admin-ajax.php, and trigger a Quick Edit. Click the request, then the Payload or Form Data tab. You will see action=inline-save, _inline_edit, post_ID, post_type, post_view, screen, and the fields the row actually exposes. Your meta key will not be there. That is the whole story in one panel.

Trace the hook order with Query Monitor. If you have Query Monitor installed, its Hooks & Actions panel shows every callback on save_post for the request, in order, with the file and line that registered each one. If your callback appears but does not write, the bail condition is the cause. If it does not appear, it was never registered on that request — usually because it is hooked to save_post_{post_type} and the post type does not match, or because it is inside an is_admin() guard that the AJAX context satisfies differently than you assumed.

Check the post_updated comparison. The post_updated hook fires after the database write and passes $post_before and $post_after. The docs are clear that these objects contain post columns only — not meta. If you were relying on post_updated to detect a meta change, it will not. Use updated_post_meta for that, and note that it only fires when the meta row actually changes.

Why the other save paths behave differently

The classic editor’s post.php submit sends the full form, including every meta box’s nonce and fields. The block editor saves via the REST API, which sends a structured payload and fires save_post through wp_update_post() as well, but with a different $_POST shape — often empty, with data arriving as JSON in the request body. WP-CLI’s wp post update calls wp_update_post() directly with an array, so $_POST is empty. XML-RPC and imports go through wp_insert_post() with an array.

Four paths, one hook, four different $_POST realities. A callback that reads $_POST unconditionally is correct on exactly one of them. A callback that derives from the post object is correct on all of them. That is the design constraint, and Quick Edit is just the path that makes it obvious because its payload is the smallest.

The save_post docs also warn about infinite loops: if your callback calls wp_update_post(), unhook before and re-hook after. Quick Edit does not change that rule. It does make it easier to hit, because a callback that recomputes meta on every save and then calls wp_update_post() to store a derived column will recurse.

FAQ

Does Quick Edit fire save_post? Yes. wp_ajax_inline_save() calls edit_post(), which calls wp_update_post(), which fires save_post. The hook is not skipped. The payload is narrower than you expect.

Why does my nonce check fail on Quick Edit? Because Quick Edit does not send your nonce. It sends _inline_edit, verified against inlineeditnonce. Your meta box nonce only exists on the classic editor form. Check for the field before checking the nonce, or derive the value instead of reading it.

Can I add a custom field to Quick Edit? Yes, via quick_edit_custom_box to render the input and JS to populate it on row open. You must also read the field in your own save_post callback, because _wp_get_allowed_postdata() will strip it before wp_update_post() sees it.

Does Quick Edit delete my postmeta? No. It does not touch meta at all. Existing values persist unchanged. That is why the failure is silent — the old value is still there, just stale.

What about post_updated? It fires after the database write and gives you before/after post objects. Those objects contain post columns, not meta. If you need to detect a meta change, hook updated_post_meta instead.

Is there a way to see which callbacks ran? Query Monitor’s Hooks & Actions panel lists every callback on save_post for the request, with file and line. It is the fastest way to distinguish “callback did not run” from “callback ran and bailed.”

If you are still chasing a save path that does not behave, the same discipline applies: find the handler, read the payload, check the whitelist, verify at the database. The first-things-first checklist for a new install that returns nothing covers the adjacent case where the symptom is a blank page rather than a stale field, and the diagnostic order is the same.