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

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 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_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 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 Your data- Attributes Survive for Some Users but Vanish for Others: wp_kses_post, the $allowedposttags Allowlist, and the unfiltered_html Capability That Decides

You add a data- attribute to a block. You save. You view the post. The attribute is there. Then another user opens the same post, changes a typo, saves, and the attribute is gone. The markup still renders, the block still works, but the data is missing. Nothing in the editor UI warned anyone.

This is not a block editor bug. It is KSES doing exactly what it was told to do, and the instruction set depends on who is saving.

Symptom

A user with the unfiltered_html capability saves a post containing markup like this:

<div class="callout" data-tone="warning" data-source="editorial">
  Text here.
</div>

The post stores the data-tone and data-source attributes. The front end reads them. Everything works.

A user without that capability opens the same post, makes an unrelated change, and saves. The stored content now reads:

<div class="callout">
  Text here.
</div>

The class survived. The data-* attributes did not. If you check the post revisions, the change is visible in the diff. If you check the database directly, the stripped version is what is stored.

Trace

Content passes through KSES on the way into the database. The relevant function is wp_kses_post(), which is a thin wrapper:

function wp_kses_post( $data ) {
    return wp_kses( $data, 'post' );
}

That is the entire function, per the WordPress developer reference for wp_kses_post(). The second argument, 'post', is a context string. It does not mean “the post being saved.” It means “use the post-content allowlist.”

That allowlist is produced by wp_kses_allowed_html( 'post' ). The function is documented at wp_kses_allowed_html(). For the 'post' context, it starts from the global $allowedposttags, then runs the result through the wp_kses_allowed_html filter.

So the chain is: wp_kses_post() → wp_kses( $data, 'post' ) → wp_kses_allowed_html( 'post' ) → $allowedposttags plus any filter additions.

The question is not whether data-* attributes are in $allowedposttags. They are not, and they never have been. The question is why the first user’s save kept them anyway.

Root cause

KSES is not applied unconditionally. It is applied when the current user lacks the unfiltered_html capability. On a single-site install, both Administrators and Editors hold that capability by default. Authors, Contributors, and Subscribers do not. On multisite, only Super Admins hold it.

The capability check is what decides whether wp_kses_post() runs at all on the content being written. When the user has unfiltered_html, the content is stored as submitted. When the user does not, the content is passed through the post-context allowlist, and anything not in that allowlist is removed.

data-* attributes are not in the default post allowlist. The WordPress developer reference for the wp_kses_allowed_html filter notes that attribute names must be added in lowercase, and the filter is the documented extension point for adding tags and attributes to a context. There is no built-in wildcard for data-*. Each attribute you want to survive must be named, or you must change who is allowed to save unfiltered content.

This is why the symptom looks role-dependent rather than content-dependent. The same markup, saved by two different users, produces two different stored results. The post content is not the variable. The capability is.

Why the block editor does not save you

The block editor sends content through the REST API. The REST posts controller applies KSES on the way in, using the same capability gate. The block editor’s client-side validation does not add attributes to the server-side allowlist. If the attribute is not in the allowlist and the user lacks unfiltered_html, the REST save strips it just as the classic editor path would.

This matters because it is tempting to assume the block editor’s structured data model protects attributes. It does not. Block attributes stored in the block comment delimiter are a separate concern from HTML attributes inside block content. A data-* attribute written into raw HTML inside a block is subject to the same KSES pass as any other HTML.

Fix

There are two honest fixes. Pick based on whether you actually want the affected users to be able to write arbitrary HTML.

Option 1: Extend the allowlist for the post context

Add the specific attributes you need to the post context. Do not add a wildcard. KSES does not support wildcards, and trying to emulate one by allowing every possible attribute name defeats the purpose of the filter.

add_filter( 'wp_kses_allowed_html', function( $tags, $context ) {
    if ( 'post' !== $context ) {
        return $tags;
    }

    $data_attrs = array(
        'data-tone'   => true,
        'data-source' => true,
    );

    foreach ( array( 'div', 'span', 'section' ) as $tag ) {
        if ( ! isset( $tags[ $tag ] ) ) {
            $tags[ $tag ] = array();
        }
        $tags[ $tag ] = array_merge( $tags[ $tag ], $data_attrs );
    }

    return $tags;
}, 10, 2 );

Two details from the documentation matter here. First, the filter receives both the tag array and the context string, so you can scope the change to 'post' and leave other contexts alone. Second, attribute names must be lowercase. data-Tone will not be recognized.

If your attributes carry URLs, note that KSES also filters protocols through kses_allowed_protocols, documented at the kses_allowed_protocols filter. A data- attribute holding a tel: or custom-scheme URL will be stripped unless the protocol is allowed.

Option 2: Grant unfiltered_html to the role that needs it

This is the correct fix only if you genuinely trust that role with unfiltered HTML. On a single-site install, unfiltered_html is held by the Administrator and Editor roles by default. On multisite, only Super Admins hold it. The capability is not a formatting convenience. It is the switch that turns KSES off for that user’s saves.

If you grant it, you are accepting that the user can store scripts, event handlers, and any other markup the allowlist would have removed. That is the trade. Do not grant it to a role whose members you would not trust with theme-file access.

Verification

Do not verify by looking at the editor. Verify at the database and at the REST boundary.

First, confirm what is actually stored:

wp db query "SELECT ID, post_author, post_status,
  LOCATE('data-tone', post_content) AS has_tone
  FROM wp_posts
  WHERE post_type = 'post'
  ORDER BY post_modified DESC
  LIMIT 10;"

A has_tone value of 0 on a post that should have the attribute confirms the strip happened at save time, not at render time.

Second, confirm the capability state for the user who saved it:

wp user get editor@example.com --field=roles
wp cap list editor | grep unfiltered_html

If unfiltered_html is absent, KSES is active for that user’s saves. That is expected.

Third, confirm the allowlist your filter produces. Run this in a context where your filter is loaded, such as a small mu-plugin or a WP-CLI eval:

wp eval '
  $tags = wp_kses_allowed_html( "post" );
  var_dump( isset( $tags["div"]["data-tone"] ) );
'

If that prints bool(false), your filter is not running in the context you think it is, or the attribute name is cased incorrectly, or the tag you targeted is not the tag in the markup.

Fourth, test the REST path directly, because that is the path the block editor uses:

wp eval '
  $content = "<div class=\"callout\" data-tone=\"warning\">x</div>";
  echo wp_kses_post( $content );
'

Run this as the user without unfiltered_html and as the user with it. The first user’s output should retain data-tone only if your filter is active. The second user’s output will retain it regardless, because wp_kses_post() called directly does not check capabilities. The capability check happens in the save pipeline, not inside wp_kses_post() itself. That distinction is the source of most confusion about this bug.

What about content saved before the role change

KSES runs on save, not on read. Content stored while the user had unfiltered_html keeps its attributes in the database. If that user later loses the capability, the stored content is not retroactively stripped. The next save by a user without the capability will strip it, because that save passes through KSES.

This is why the bug often appears after a role change, a multisite migration, or a change to who is editing a given post. The content did not change. The capability of the person saving it did.

Why this is not a bug to report upstream

KSES is an allowlist by design. The default post allowlist is deliberately conservative. data-* attributes are not in it because the set of possible attribute names is unbounded, and an allowlist that permits all of them is not an allowlist. The extension point is the wp_kses_allowed_html filter, and it exists precisely so that sites can name the attributes they need.

The failure mode is not that KSES strips your attributes. It is that the strip is silent, capability-dependent, and only visible in the stored content. Once you know the capability gate is the variable, the fix is a filter or a capability grant, and the verification is a database query.

FAQ

Does wp_kses_post() check capabilities?

No. It filters unconditionally against the post allowlist. The capability check happens in the save pipeline, which decides whether to call it. Calling wp_kses_post() directly in a theme or plugin will strip attributes regardless of who is logged in.

Can I allow all data-* attributes at once?

Not through the documented filter. KSES matches attribute names against the allowlist array. There is no wildcard syntax. You can name each attribute you need, or you can grant unfiltered_html and accept the consequences.

Why did the attribute survive in the editor preview but not after save?

The editor preview renders from the in-memory content before the save round-trip. The save round-trip is where KSES runs. The preview is not evidence that the attribute will persist.

Does this affect custom blocks?

If the custom block stores its data in block attributes (the JSON in the block comment delimiter), those are not subject to KSES the same way. If it stores data in HTML attributes inside the block’s rendered content, those are subject to KSES on save by users without unfiltered_html.

Where is unfiltered_html defined for multisite?

On multisite, unfiltered_html is restricted to super admins by default. The current_user_can_for_site() function, documented at the developer reference, is the site-scoped capability check. If you are debugging a multisite install, check the capability in the context of the site where the save happened, not the network context.

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.

Why Your Nav Menu Still Links to a Post You Trashed Last Week: nav_menu_item Rows in wp_posts, post_status Inheritance, and _menu_item_object_id That Never Gets Cleaned

You trashed a post. The menu still links to it. The link may 404, or it may resolve to the trash preview if you are logged in. Either way, the menu item is still there, still published, still pointing at an object that no longer exists in any public sense.

This is not a bug in the sense of a crash. It is a lifecycle mismatch. Menu items are posts. They live in wp_posts with post_type = 'nav_menu_item'. They reference their target through post meta, primarily _menu_item_object_id. When you trash the target, WordPress does not trash the menu item. When you permanently delete the target, WordPress does clean up the menu item — but only if the deletion path reaches the right hook. Trashing is the gap.

Symptom

A post is moved to Trash. The navigation menu still renders a link to it. The menu item appears in Appearance → Menus as if nothing happened. In the block editor’s Navigation block, the item may show as a link with a title but no resolved object, or it may silently disappear from the editor while remaining in the database and in classic menu output.

If you inspect the front end, the anchor’s href is still the old permalink. If the post is trashed, that permalink may return a 404 for anonymous visitors. If the post is later restored, the link works again. If the post is permanently deleted, the menu item may vanish from the menu — or it may not, depending on how the deletion was performed.

Trace

Start with the database. Menu items are rows in wp_posts:

SELECT ID, post_title, post_status, post_type, post_date
FROM wp_posts
WHERE post_type = 'nav_menu_item'
ORDER BY ID DESC
LIMIT 20;

Each menu item’s target is stored in wp_postmeta:

SELECT p.ID, p.post_title, p.post_status,
       oid.meta_value AS object_id,
       otype.meta_value AS object_type,
       otax.meta_value AS object_taxonomy
FROM wp_posts p
LEFT JOIN wp_postmeta oid   ON oid.post_id = p.ID   AND oid.meta_key = '_menu_item_object_id'
LEFT JOIN wp_postmeta otype ON otype.post_id = p.ID AND otype.meta_key = '_menu_item_object'
LEFT JOIN wp_postmeta otax  ON otax.post_id = p.ID  AND otax.meta_key = '_menu_item_object'
WHERE p.post_type = 'nav_menu_item'
ORDER BY p.ID DESC;

Now join against the target posts to find orphans:

SELECT mi.ID AS menu_item_id,
       mi.post_title AS menu_item_title,
       mi.post_status AS menu_item_status,
       oid.meta_value AS target_id,
       t.post_status AS target_status,
       t.post_type AS target_type
FROM wp_posts mi
JOIN wp_postmeta oid ON oid.post_id = mi.ID AND oid.meta_key = '_menu_item_object_id'
LEFT JOIN wp_posts t ON t.ID = oid.meta_value
WHERE mi.post_type = 'nav_menu_item'
  AND (t.ID IS NULL OR t.post_status = 'trash');

That query returns menu items whose target is either missing entirely or sitting in Trash. The first case is a permanent-deletion orphan. The second is the trash-inheritance case.

Why does the menu item survive trashing? Because wp_trash_post() changes the target post’s post_status to trash. It does not touch nav_menu_item rows. The menu item’s own post_status remains publish (or inherit in some older data). There is no core routine that walks wp_posts looking for nav_menu_item rows whose _menu_item_object_id matches the trashed post ID.

Permanent deletion is different. wp_delete_post() fires before_delete_post, delete_post, deleted_post, and the dynamic delete_post_{$post_type} / deleted_post_{$post_type} hooks. Core registers callbacks on those hooks for menu cleanup: _wp_delete_post_menu_item() and _wp_delete_tax_menu_item(). Those callbacks query for nav_menu_item rows referencing the deleted object and delete them. That is why permanent deletion usually cleans up the menu. Trashing does not, because trashing is not deletion.

The documentation for wp_delete_post() states that when a post is permanently deleted, “everything that is tied to it is deleted also,” including comments, post meta, and terms. It also notes that the function reverts to wp_trash_post() when $force_delete is false and the post type is post or page and the item is not already trashed. That revert is the fork in the road: the cleanup hooks fire on the permanent-delete path, not on the trash path.

At render time, wp_get_nav_menu_items() and wp_setup_nav_menu_item() resolve the referenced object. If the object no longer exists, the menu item may still be returned with a stale URL, or it may be filtered out depending on the code path and the menu item type. The key point is that resolution happens at render time, not at trash time. Nothing in the trash operation invalidates the menu item.

The REST API exposes menu items through /wp/v2/menu-items. Orphaned rows can appear there because the controller reads the same wp_posts and wp_postmeta data. The block editor’s Navigation block consumes that data. When the referenced object is missing, the block may render a fallback label or drop the item from the editor view, but the underlying row remains. This is why the classic menu screen and the block editor can disagree about what exists.

Root cause

Menu items are posts with their own lifecycle. Their relationship to the target object is stored in post meta, not in a foreign key. WordPress core cleans that relationship on permanent deletion, not on trashing. We found no documented core action or filter whose purpose is to clean menu items when a post is trashed. The deleted_post hook fires after permanent deletion; the trashed_post hook fires after trashing, but we found no evidence that core attaches menu cleanup to it.

The result is a predictable orphan class: nav_menu_item rows whose _menu_item_object_id points at a post in trash. They remain published, they remain in the menu, and they remain in the REST API. If the post is later restored, the link works again. If the post is permanently deleted, the cleanup hooks fire and the menu item is removed — unless the deletion happened through a path that bypasses those hooks, such as direct SQL or a custom deletion routine.

Fix

You have two choices: clean up on trash, or clean up on a schedule. Cleaning up on trash is more immediate but changes editorial behavior: a trashed post that is restored will lose its menu item. Cleaning up on a schedule preserves the restore path but leaves a window where the menu links to a trashed post.

If you want trash-time cleanup, hook trashed_post and delete the matching menu items. The following is a minimal implementation. It queries for nav_menu_item rows whose _menu_item_object_id matches the trashed post ID, then deletes them with wp_delete_post() using true for $force_delete so the menu item is permanently removed rather than trashed.

add_action( 'trashed_post', function ( $post_id ) {
    global $wpdb;

    $menu_item_ids = $wpdb->get_col( $wpdb->prepare(
        "SELECT p.ID
         FROM {$wpdb->posts} p
         JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID
         WHERE p.post_type = 'nav_menu_item'
           AND pm.meta_key = '_menu_item_object_id'
           AND pm.meta_value = %d",
        $post_id
    ) );

    foreach ( $menu_item_ids as $menu_item_id ) {
        wp_delete_post( (int) $menu_item_id, true );
    }
} );

If you prefer scheduled cleanup, run a WP-CLI command or a cron event that finds orphans and removes them. The query is the same as the audit query above. The deletion is the same wp_delete_post( $id, true ) call.

If you want to preserve the menu item across trash and restore, do not delete on trash. Instead, filter the menu output to skip items whose target is trashed. That keeps the row in the database and restores the link when the post is restored. The trade-off is that the orphan remains visible to anything reading the REST API or the database directly.

Verification

After applying a fix, verify with the audit query. It should return zero rows for the trashed post ID. Then check the front end: the menu should no longer link to the trashed post. Check the REST API:

curl -s 'https://example.com/wp-json/wp/v2/menu-items?per_page=100' \
  | jq '.[] | select(.meta._menu_item_object_id == "123")'

Replace 123 with the trashed post ID. If the response is empty, the menu item is gone from the REST surface. If you are using the block editor, open the Navigation block and confirm the item is no longer listed.

For a WP-CLI audit without writing a plugin, use wp db query:

wp db query "
SELECT mi.ID AS menu_item_id, mi.post_title, oid.meta_value AS target_id, t.post_status
FROM wp_posts mi
JOIN wp_postmeta oid ON oid.post_id = mi.ID AND oid.meta_key = '_menu_item_object_id'
LEFT JOIN wp_posts t ON t.ID = oid.meta_value
WHERE mi.post_type = 'nav_menu_item'
  AND (t.ID IS NULL OR t.post_status = 'trash');
"

If you want to delete the orphans returned by that query, pipe the IDs into wp post delete:

wp db query --skip-column-names "
SELECT mi.ID
FROM wp_posts mi
JOIN wp_postmeta oid ON oid.post_id = mi.ID AND oid.meta_key = '_menu_item_object_id'
LEFT JOIN wp_posts t ON t.ID = oid.meta_value
WHERE mi.post_type = 'nav_menu_item'
  AND (t.ID IS NULL OR t.post_status = 'trash');
" | xargs -r -n1 wp post delete --force

That command deletes the menu items permanently. It does not touch the trashed posts themselves. If you want to restore a trashed post and keep its menu item, do not run the deletion; run the audit and decide per item.

What about the block editor?

The Navigation block reads menu items through the REST API. When the referenced object is missing, the block’s client-side code may not render the item, but the server-side row remains. This is why the editor and the front end can disagree. The fix is the same: clean the row, or filter it out at render time. There is no block-editor-specific cleanup that runs on trash.

If you are debugging the block editor, check the network tab for /wp/v2/menu-items and inspect the response. If the orphan is present there, it is present in the database. If it is absent there but present in the classic menu, you are looking at a caching or capability difference, not a data difference.

FAQ

Does WordPress clean up menu items when a post is trashed?
No. Core cleans up menu items on permanent deletion through _wp_delete_post_menu_item() and related callbacks. Trashing changes the target’s post_status to trash and leaves the menu item untouched.

Why does the menu item still have post_status = 'publish'?
Because the menu item is its own post. Its status is independent of the target’s status. Trashing the target does not cascade to the menu item’s status.

What is _menu_item_object_id?
It is the post meta key that stores the ID of the object the menu item points to. For a post-type menu item, it is the post ID. For a taxonomy menu item, it is the term ID. The companion key _menu_item_object stores the object type, such as post or page.

Can I restore a trashed post and get the menu item back?
Yes, if you did not delete the menu item. The menu item row remains in the database, so restoring the post makes the link work again. If you delete the menu item on trash, restoring the post will not restore the menu item.

Does the REST API expose orphaned menu items?
Yes. The /wp/v2/menu-items endpoint reads from wp_posts and wp_postmeta. If the row exists, it can appear in the response. The block editor’s Navigation block consumes that endpoint, which is why orphans can surface there.

Is there a core filter for menu item cleanup on trash?
We found no documented core action or filter whose purpose is to clean menu items when a post is trashed. You can attach your own callback to trashed_post.

What about custom post types?
The same logic applies. Menu items can point to custom post types. Trashing a custom post type item does not clean the menu item. Permanent deletion does, provided the deletion goes through wp_delete_post() and the relevant hooks fire.

How do I find all orphaned menu items?
Use the audit query in the Verification section. It returns menu items whose target is missing or trashed. Run it with wp db query or directly in your database client.

Will this affect performance?
The audit query joins wp_posts and wp_postmeta. On a typical publishing site, the number of nav_menu_item rows is small. The query is not a concern at that scale. If you have thousands of menu items, add an index on meta_key and meta_value if your host allows it, or run the audit during low traffic.

What if I use a page builder or a custom menu system?
The underlying data model is the same if the menu is stored as nav_menu_item posts. If the builder stores its own menu data elsewhere, this article does not apply. Check the builder’s documentation for its own cleanup behavior.

Related reading

If you are debugging a fresh install where the front page says nothing found, the failure mode is different but the diagnostic habit is the same: trace the data, not the symptom. See What to Fix First When a New WordPress Site Says Nothing Found.

Sources

  • wp_delete_post() — documents the trash-versus-delete fork, the hooks that fire, and the cleanup of related data on permanent deletion.
  • deleted_post hook — fires immediately after a post is deleted from the database.
  • WP_Query — documents the query class used to retrieve posts, including menu items, and the parameters relevant to post type and status.
  • Block attributes — explains how block attributes are stored and parsed, which is relevant to how the Navigation block reads menu data.

Why Your Revision History Shrinks Overnight: wp_revisions_to_keep, the post_type=’revision’ Rows in wp_posts, and the postmeta That Revisions Never Carry

You open a post that had forty revisions yesterday. Today it has twelve. Nobody ran a cleanup plugin. Nobody touched the database. The number just dropped, and the oldest entries are gone. This is not a bug in the sense of a crash; it is the revision retention system doing exactly what it was told to do, at a moment you did not expect.

The symptom is almost always the same: a post’s revision count falls to a round number — 5, 10, 12 — and the surviving revisions are the most recent ones. The trace runs through wp_save_post_revision(), the wp_revisions_to_keep filter, and the post_type='revision' rows in wp_posts. The root cause is usually a filter or constant that changed the retention limit, combined with the fact that pruning is synchronous and silent. The fix is to find which layer set the limit, then verify against the database directly.

Symptom: the count drops without a scheduled event

WP-Cron does not delete revisions. There is no scheduled event in core that prunes revision rows. Pruning happens inside wp_save_post_revision(), which runs synchronously on post save, and inside wp_delete_post_revision() when a revision is explicitly deleted. If your revision count shrank, a save happened, and that save triggered the retention check.

This matters because the first instinct is to look at cron jobs, cleanup plugins, or database maintenance scripts. Those are the wrong places. The right place is the save path.

Trace: what wp_save_post_revision() actually does

The function is documented in the WordPress developer reference. The relevant sequence, per the source shown there, is:

  1. Bail if DOING_AUTOSAVE is defined and true.
  2. Bail if the post type does not support revisions.
  3. Bail if the post status is auto-draft.
  4. Bail if wp_revisions_enabled() returns false.
  5. Compare the current post against the latest stored revision. If nothing changed, return null.
  6. Insert the new revision via _wp_put_post_revision().
  7. Call wp_revisions_to_keep( $post ) to get the retention limit.
  8. If the limit is less than zero, return without deleting anything.
  9. Fetch all revisions in ascending order, apply the wp_save_post_revision_revisions_before_deletion filter, compute how many to delete, and delete the oldest ones — skipping any whose post_name contains autosave.

The deletion loop calls wp_delete_post_revision() for each old revision. That function deletes the revision row and its associated postmeta. It does not touch the parent post’s postmeta.

So the shrink is not a background process. It is the tail end of a save you or an editor performed, possibly hours before you noticed.

Root cause: the retention limit is set in more than one place

The number of revisions kept is determined by wp_revisions_to_keep(), which resolves in this order:

  1. The WP_POST_REVISIONS constant in wp-config.php, if defined.
  2. The wp_revisions_to_keep filter, which overrides the constant.

The developer reference for the filter states plainly: “Overrides the value of WP_POST_REVISIONS.” It also notes that by default, an infinite number of revisions are stored if a post type supports revisions. The filter callback receives the current number and the post object, and must return a value — if it returns nothing, revisions will be empty.

The wp-config.php documentation confirms the constant behavior: if you do not set WP_POST_REVISIONS, WordPress defaults it to true (enable post revisions). You can set it to false to disable revisions, or to an integer to cap the number stored.

Here is the failure mode. A site has define( 'WP_POST_REVISIONS', 12 ); in wp-config.php. That is the intended cap. But a plugin or theme adds a filter that returns a different number for certain post types. The filter wins. The editor sees twelve revisions on one post and five on another, and assumes the database is inconsistent. It is not. Two different limits are being applied to two different post types.

A second failure mode: the filter callback returns nothing. The documentation warns about this explicitly. If the callback does not return a value, the revisions will be empty — meaning the retention limit becomes zero or null, and the next save prunes everything except the current revision.

The rows: what a revision actually is in wp_posts

A revision is a row in wp_posts with post_type = 'revision'. The wp_get_post_revisions() function, per its source, merges these arguments into the query:

'post_parent' => $post->ID,
'post_type'   => 'revision',
'post_status' => 'inherit',

It defaults to order => 'DESC' and orderby => 'date ID'. The function also checks wp_revisions_enabled() unless check_enabled is set to false.

To see the actual rows for a post, run this SQL against your database. Replace the prefix and post ID as needed:

SELECT ID, post_parent, post_name, post_date, post_status, post_type
FROM wp_posts
WHERE post_parent = 1234
  AND post_type = 'revision'
ORDER BY post_date ASC, ID ASC;

The post_name column is the tell. Core revisions have names like 1234-revision-v1. Autosaves have names containing autosave. The deletion loop in wp_save_post_revision() skips autosaves when pruning, which is why you can sometimes see more rows than the retention limit suggests — autosaves are not counted the same way.

If you want to count revisions per post without loading them into memory, use WP-CLI:

wp post list --post_type=revision --post_parent=1234 --format=count

Or, for a broader audit across all posts:

wp db query "SELECT post_parent, COUNT(*) AS revisions
FROM wp_posts
WHERE post_type = 'revision'
GROUP BY post_parent
ORDER BY revisions DESC
LIMIT 20;"

The postmeta that revisions never carry

This is the part that surprises people. When a revision is created, WordPress copies the revisioned fields — the ones returned by _wp_post_revision_fields() — into the revision row. Those are typically post_title, post_content, post_excerpt, and a few others depending on the post type.

What it does not copy is the parent post’s postmeta. Custom fields, SEO metadata, template selections, and anything else stored in wp_postmeta are not versioned by core. If you restore a revision, you get the post fields back, but the postmeta stays as it was at the moment of restore — not as it was when the revision was saved.

This has a direct consequence for the shrink symptom. If you are counting revisions by querying wp_postmeta for a revision-related key, you are counting the wrong thing. Revisions do not carry the parent’s meta keys. They may have their own meta rows, but those are not a copy of the parent’s.

To verify what meta a revision actually has, run:

SELECT meta_key, meta_value
FROM wp_postmeta
WHERE post_id = 5678; -- the revision ID, not the parent

If that returns nothing, the revision has no meta. That is normal. It is not evidence of data loss.

The block editor and the REST autosave path

The block editor does not save revisions the same way the classic editor did. It uses the REST API, and autosaves go through WP_REST_Autosaves_Controller. The wp_get_post_revisions() documentation lists that controller as a consumer of the function, specifically for get_items().

Autosaves are stored as revisions with post_name containing autosave. They are subject to the same retention logic, but the deletion loop skips them. This means an editor who works in the block editor for a long session can accumulate autosave rows that are not pruned by the normal count. When they finally publish, a core revision is created, and the pruning runs against the non-autosave revisions.

If you are debugging a shrink and the editor uses the block editor, check whether the missing revisions were autosaves or core revisions. The SQL above distinguishes them by post_name.

Fix: find the layer that set the limit

Start with wp-config.php. Search for WP_POST_REVISIONS. If it is defined, note the value. If it is not defined, the default is true, which means unlimited revisions for post types that support them.

Next, search your plugins and theme for wp_revisions_to_keep. This filter can be added anywhere. A common pattern is a plugin that sets a different limit per post type:

add_filter( 'wp_revisions_to_keep', function( $num, $post ) {
    if ( 'post' === $post->post_type ) {
        return 5;
    }
    return $num;
}, 10, 2 );

If you find a filter like this, that is your limit. The constant is being overridden. To confirm, you can temporarily log the resolved value. Add this to a mu-plugin or a theme’s functions.php during debugging:

add_filter( 'wp_revisions_to_keep', function( $num, $post ) {
    error_log( sprintf(
        'Revisions to keep for post %d (%s): %d',
        $post->ID,
        $post->post_type,
        $num
    ) );
    return $num;
}, 9999, 2 );

Check your debug log after saving a post. The logged number is the effective limit. If it differs from what you expected, trace back to the filter or constant that set it.

If you want to raise the limit, do it in one place. Either set WP_POST_REVISIONS in wp-config.php or use the filter, not both. If you use the filter, make sure every callback returns a value. The documentation’s warning about empty revisions is not theoretical; a callback that returns nothing will cause the retention limit to collapse.

Verification: confirm the rows match the limit

After changing the limit, save a post a few times and then query the database. The count should stabilize at the new limit, plus any autosaves that were skipped.

SELECT COUNT(*) AS total,
       SUM(CASE WHEN post_name LIKE '%autosave%' THEN 1 ELSE 0 END) AS autosaves,
       SUM(CASE WHEN post_name NOT LIKE '%autosave%' THEN 1 ELSE 0 END) AS core_revisions
FROM wp_posts
WHERE post_parent = 1234
  AND post_type = 'revision';

If core_revisions is greater than your limit, check whether the wp_save_post_revision_revisions_before_deletion filter is altering the list. That filter, introduced in WordPress 6.2, receives the array of revisions before deletion and the post ID. A plugin can use it to exclude certain revisions from pruning.

If core_revisions is less than your limit, check whether the post type supports revisions at all. post_type_supports( $post->post_type, 'revisions' ) must return true. If it does not, no revisions are saved, and the count will be zero regardless of the limit.

What this is not

This is not a caching problem. Object cache and transient caches can make a count appear stale, but they do not delete rows. If the rows are gone from wp_posts, the deletion happened. If the rows are present but the count looks wrong, you are probably querying through a cached layer or counting the wrong post type.

This is also not a WP-Cron problem. There is no core cron event that prunes revisions. If a plugin registers one, that is a plugin behavior, not core behavior. Check wp_get_schedules() and your cron events if you suspect a plugin, but start with the save path.

FAQ

Does wp_delete_post_revision() delete the parent post’s postmeta?
No. It deletes the revision row and the revision’s own postmeta. The parent post’s meta is untouched.

Why do I see more revisions than my limit?
Autosaves are skipped during pruning. They are stored as revisions but are not counted against the limit in the same way. Check post_name for autosave.

Can I keep revisions forever?
Yes. Do not define WP_POST_REVISIONS and do not add a wp_revisions_to_keep filter that returns a finite number. The default is unlimited for post types that support revisions.

What happens if my filter callback returns nothing?
The documentation states that revisions will be empty. In practice, the retention limit collapses and the next save prunes aggressively. Always return a value from the callback.

Where is the retention limit resolved?
In wp_revisions_to_keep(), which applies the wp_revisions_to_keep filter to the value of WP_POST_REVISIONS. The filter overrides the constant.

Primary sources

If you are debugging a fresh install that returns nothing at all, the failure mode is different: what to fix first when a new WordPress site says nothing found covers the query and rewrite layer rather than the revision retention layer.

Why Mobile Downloads the 4MB Original: wp_calculate_image_srcset, Missing Intermediate Sizes, and the sizes Default

Your phone loads a 4MB JPEG on a 3G connection while the desktop browser gets a 180KB WebP. The HTML looks correct: an <img> with a src, a srcset, and a sizes attribute. The problem is not the markup. The problem is that wp_calculate_image_srcset() returned false, WordPress emitted the full-size URL as the sole src, and the browser had no candidates to choose from. This article traces that failure from the rendered tag back to _wp_attachment_metadata, through the core hooks that decide what goes into srcset, and ends with a verification procedure that does not require regenerating every thumbnail on the site.

Symptom

Open DevTools, switch to a mobile viewport, and inspect the image request. You see one of these patterns:

  • The <img> has a src pointing at the original upload (for example, 2026/09/hero.jpg) and no srcset attribute at all.
  • The <img> has a srcset with a single candidate, usually the full-size file, and a sizes attribute that claims the image will render at 100vw.
  • The srcset exists but contains only the thumbnail and medium candidates, so the browser picks the largest available and upscales it on a 1440px viewport.

In all three cases, the network panel shows the mobile device downloading a file that is several megabytes larger than necessary. The desktop browser may look fine because it has a wider viewport and the browser’s own heuristics pick a different candidate, or because the desktop cache already holds the original.

Trace

Start at the function that builds the attribute. wp_calculate_image_srcset() is documented as returning string|false, where false means an error or that only one source exists. The source code shows the early exits:

if ( empty( $image_meta['sizes'] ) || ! isset( $image_meta['file'] ) || strlen( $image_meta['file'] ) < 4 ) {
    return false;
}

That is the first place to look. If _wp_attachment_metadata has no sizes key, or the key is an empty array, the function bails before it ever inspects the requested dimensions. The wp_get_attachment_metadata() reference confirms the expected shape: sizes is an array keyed by size slug, and each value contains file, width, height, and mime-type. If that array is missing or malformed, srcset cannot be built.

If sizes is present, the function loops through each entry and applies several filters. The relevant ones for this failure are:

  • wp_calculate_image_srcset_meta — runs before the early exit, so a plugin can repair $image_meta here.
  • max_srcset_image_width — defaults to 2048, and excludes any candidate wider than that unless it is the src image.
  • wp_calculate_image_srcset — runs after the loop, and can add or remove candidates.

The loop also checks wp_image_matches_ratio(). Only candidates whose aspect ratio matches the requested size within one pixel are included. A cropped intermediate size with a different ratio is silently skipped. That is documented behavior, not a bug: srcset is for resolution switching, not art direction.

After the loop, the function checks $src_matched. If no candidate’s filename appears inside the src URL, the function returns false to avoid serving an incorrect image. This is the second common failure point. If the src points at a file that is not represented in sizes — for example, because the original was replaced or the metadata was written by a different process — the match fails and srcset is suppressed.

Finally, the function returns false if fewer than two sources remain after filtering. A single candidate is not a srcset; it is just a src with extra steps.

When wp_calculate_image_srcset() returns false, wp_get_attachment_image_srcset() returns false, and wp_img_tag_add_srcset_and_sizes_attr() does not add the attributes. The <img> keeps whatever src the block or theme supplied. In the block editor, that is often the full-size URL when the image block’s sizeSlug is unset or set to full.

Root cause

The root cause is almost always an incomplete sizes array in _wp_attachment_metadata. The array is written by wp_generate_attachment_metadata() during upload, and updated by wp_create_image_subsizes() and wp_update_image_subsizes(). If any of those steps fail, or if the registered sizes change after upload, the stored metadata no longer reflects the files on disk.

Three failure modes produce this state:

  1. The upload request timed out before sub-sizes were generated. The original file was written, the attachment post was created, but wp_generate_attachment_metadata() did not finish. The sizes key is missing or partial.
  2. The image editor could not process the file. WP_Image_Editor is abstract; the concrete implementation is WP_Image_Editor_GD or WP_Image_Editor_Imagick. If neither supports the mime type, or if the server lacks memory, multi_resize() returns a WP_Error and the sub-sizes are not recorded.
  3. The registered sizes changed after upload. A theme or plugin registered a new size, or the medium_size_w option was changed. Existing attachments do not automatically get the new size. wp_get_missing_image_subsizes() exists to compare stored metadata against currently registered sizes, but it only runs when something calls it — typically the admin media screen or a WP-CLI command.

There is also a subtler case: the sizes array is present and populated, but the src URL does not match any entry. This happens when the block editor stores a sizeSlug that no longer exists, or when the image was edited in WordPress and the metadata contains a hash suffix that the src does not include. The wp_calculate_image_srcset() source checks for the edit hash and filters out candidates from previous edits. If the src is from an older edit, no candidate matches and the function returns false.

Fix

Do not start by regenerating thumbnails. Start by reading the metadata. The following WP-CLI command prints the sizes array for a given attachment ID:

wp post meta get 1234 _wp_attachment_metadata --format=json | jq '.sizes'

If jq is not available, use wp eval:

wp eval '
$meta = wp_get_attachment_metadata( 1234 );
if ( empty( $meta["sizes"] ) ) {
    echo "sizes is empty or missing\n";
} else {
    foreach ( $meta["sizes"] as $slug => $data ) {
        printf( "%s: %dx%d (%s)\n", $slug, $data["width"], $data["height"], $data["file"] );
    }
}
'

Compare that output to the currently registered sizes:

wp eval 'print_r( wp_get_registered_image_subsizes() );'

wp_get_registered_image_subsizes() returns a normalized list of all currently registered sub-sizes, keyed by name. If a size is registered but absent from the attachment metadata, the file may or may not exist on disk. Check the uploads directory directly:

wp eval '
$meta = wp_get_attachment_metadata( 1234 );
$dir  = wp_get_upload_dir();
$path = trailingslashit( $dir["basedir"] ) . dirname( $meta["file"] );
foreach ( wp_get_registered_image_subsizes() as $slug => $size ) {
    if ( empty( $meta["sizes"][ $slug ] ) ) {
        echo "missing metadata: $slug\n";
    }
}
'

If the metadata is missing entries but the files exist, the fix is to update the metadata without re-encoding. wp_update_image_subsizes() is the core function for this, but it is defined in wp-admin/includes/image.php, so it is not loaded on the front end. A WP-CLI command can load it:

wp eval '
require_once ABSPATH . "wp-admin/includes/image.php";
$updated = wp_update_image_subsizes( 1234 );
if ( is_wp_error( $updated ) ) {
    echo $updated->get_error_message() . "\n";
} else {
    echo "updated\n";
}
'

If the files do not exist, wp_update_image_subsizes() will attempt to create them. That is the correct behavior, but it requires a working image editor and sufficient memory. If the server cannot create the files, the metadata will remain incomplete and srcset will continue to fail.

For a diagnostic view without modifying anything, use the wp_calculate_image_srcset_meta filter. It runs before the early exit and receives the raw $image_meta array. A temporary mu-plugin can log the array for a specific attachment:

add_filter( 'wp_calculate_image_srcset_meta', function( $image_meta, $size_array, $image_src, $attachment_id ) {
    if ( 1234 === $attachment_id ) {
        error_log( print_r( $image_meta, true ) );
    }
    return $image_meta;
}, 10, 4 );

That filter is also the correct place to repair inconsistencies in stored data, as the documentation states. If you need to add a candidate that is not in sizes, use wp_calculate_image_srcset instead. It receives the final $sources array and can append or remove entries. The hook reference includes an example that adds a custom header image source.

Do not use intermediate_image_sizes_advanced to fix this. That filter runs during upload and changes which sizes are generated. It does not repair existing metadata, and it does not affect wp_calculate_image_srcset() at render time.

Verification

After updating the metadata, verify the rendered output. The most direct check is to fetch the attachment’s srcset value:

wp eval '
$srcset = wp_get_attachment_image_srcset( 1234, "large" );
var_dump( $srcset );
'

A non-false string means the function found at least two matching candidates. If it returns false, the metadata is still incomplete or the src does not match any entry.

Next, inspect the actual HTML. Use curl with a mobile user agent, or open the page in a browser and view source. Look for the srcset attribute and confirm it contains multiple widths. The sizes attribute should also be present. If sizes is missing, the browser defaults to 100vw, which is often wrong for a content column. That default is not a WordPress bug; it is the HTML specification’s behavior when sizes is absent.

Finally, check the network request in DevTools. With a mobile viewport, the browser should request an intermediate size, not the original. If it still requests the original, check whether the src attribute itself points at the full-size file. The src is the fallback for browsers that do not support srcset, and it is also the image the browser uses if no candidate matches. In the block editor, the image block’s sizeSlug attribute controls which size is used for src. If it is set to full, the src will be the original even when srcset is present. The browser may still choose a smaller candidate from srcset, but the fallback is the large file.

To confirm the block editor’s stored value, inspect the post content:

wp post get 5678 --field=content | grep -o 'sizeSlug[^}]*'

If the block has no sizeSlug, WordPress uses the default. The default is not a fixed size name; it depends on the block’s attributes and the theme’s settings. In practice, an image block without an explicit size often falls back to full for the src, while srcset still offers smaller candidates. That is acceptable for modern browsers, but it means the src is not a small file. If you need the src itself to be an intermediate size, set the block’s size explicitly in the editor or filter the rendered block.

What not to do

Do not install a plugin that rewrites srcset before you have confirmed the metadata is complete. The core function is not broken; it is returning false because the data it needs is missing. A filter that forces a srcset string can produce candidates that do not exist on disk, which results in broken images on some viewports.

Do not regenerate all thumbnails as a first step. On a large library, that is a long-running operation that may time out and leave the site in a worse state. Use wp_get_missing_image_subsizes() to identify which attachments actually need work, and process them in batches.

Do not assume the problem is the sizes attribute. The sizes attribute tells the browser how wide the image will be displayed. It does not create candidates. If srcset is missing, sizes has nothing to select from.

FAQ

Why does the desktop browser load a small image but mobile loads the original?

Desktop browsers often have a wider viewport and a larger device pixel ratio, so they may select a larger candidate from srcset. If srcset is missing entirely, both desktop and mobile load the src. The difference you see may be caching: the desktop browser already has an intermediate size cached from a previous request, while mobile is requesting the src for the first time. Check the actual src and srcset attributes in the HTML, not just the network panel.

Does wp_calculate_image_srcset() include the full-size image?

Yes, unless the image is an animated GIF. The source code appends the full-size dimensions to the $image_sizes array before the loop, unless the thumbnail’s mime type is image/gif. For GIFs, the behavior differs to avoid hiding animation. For JPEG, PNG, and WebP, the full-size image is a candidate, but it is subject to the max_srcset_image_width filter, which defaults to 2048 pixels. If the original is wider than 2048 and is not the src image, it is excluded from srcset.

Can I fix this without WP-CLI?

Yes. The same functions are available in the admin. The media library’s edit screen may offer a regenerate option depending on your setup, but the core function wp_update_image_subsizes() is not exposed as a button by default. A small admin-side script or a one-time wp eval command is usually faster than clicking through the media library. If you prefer a plugin, choose one that calls the core functions rather than reimplementing them.

What if the metadata is correct but srcset is still missing?

Check the src URL against the file values in sizes. The function requires a match. If the src points at a CDN URL or a resized version that is not in the metadata, the match fails. The wp_calculate_image_srcset_meta filter can be used to normalize the metadata, or the wp_calculate_image_srcset filter can be used to add the missing source. Both are documented hooks, and both are preferable to filtering the final HTML.

Does the REST API affect this?

The REST API’s attachments controller uses wp_get_attachment_metadata() and wp_get_registered_image_subsizes() when preparing responses and validating image dimensions. If the metadata is incomplete, the REST response may omit size information, and the block editor may not offer the missing sizes in the image block’s size dropdown. That is a symptom, not a cause. Fixing the metadata fixes the REST response.

What about WP-Cron?

WP-Cron does not generate image sub-sizes by default. It may be used by plugins to schedule background processing, but core’s upload flow generates sub-sizes synchronously during the upload request. If the upload request times out, WP-Cron is not a fallback. The wp_generate_attachment_metadata() function runs in the same request that handles the upload. If that request is killed, the metadata is incomplete and no scheduled event will repair it unless a plugin explicitly schedules one.

For a related failure mode where a new site returns no results because the query is looking for content that was never published, see What to Fix First When a New WordPress Site Says Nothing Found. The diagnostic pattern is similar: read the stored data before changing the rendering layer.