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.