update_post_meta() in WordPress: Return Values, prev_value, Arrays & REST

update_post_meta() adds or updates a post's custom field. It returns the meta ID when it adds a row, true when the value changes, and false when it fails or the value is unchanged. Tested on WordPress 7.1.3.

Abhilash SahooFounder & CEO, Infyways
Published
Updated
Reading time
9 min read
update_post_meta() in WordPress: syntax and return values, tested on WordPress 7.1
On this page 16 sections

Updated 16 October 2026 · By Abhilash Sahoo, Infyways

Quick answer: update_post_meta() saves a custom field (post meta) value. If the key doesn't exist for that post, it adds it; if it does, it updates it.

$result = update_post_meta( $post_id, $meta_key, $meta_value, $prev_value = '' );
  • Returns the new meta ID (int) when it adds a row, true when it changes a value, and false on failure or when the value is unchanged.
  • Without $prev_value it overwrites every row with that key.
  • Arrays are serialized for you, and backslashes are stripped unless you wrap the value in wp_slash().

Every result on this page comes from a real WordPress 7.1.3 install (PHP 8.4) tested with WP-CLI, not copied from the manual.

What are the parameters of update_post_meta()?

The signature from the official WordPress developer reference is:

update_post_meta( int $post_id, string $meta_key, mixed $meta_value, mixed $prev_value = '' ): int|bool
ParameterTypeNotes
$post_idintRequired. If you pass a revision ID, WordPress saves the meta on the parent post instead.
$meta_keystringRequired. Start it with _ to hide it from the Custom Fields box.
$meta_valuemixedRequired. Arrays and objects are serialized. Expected to be slashed.
$prev_valuemixedOptional. Only update rows that currently hold this value. Default '' means all rows with the key.

Internally, update_post_meta() is a thin wrapper: it swaps a revision ID for its parent and then calls update_metadata( 'post', ... ), the same engine behind user, term and comment meta.

What does update_post_meta() return?

Real wp-cli output of update_post_meta return values: meta ID, true and false
Situation (tested)Return
Key didn't exist, so a row was addedMeta ID, e.g. 5
Key existed and the value changedtrue
Same value as storedfalse
Integer 5 stored, then string "5" savedfalse (treated as unchanged)
$post_id of 0, or an empty $meta_keyfalse
A post ID that doesn't exist (999999)Meta ID: the row is still added
A filter on update_post_metadata short-circuits itWhatever the filter returns

Two practical lessons follow. First, don't treat false as an error: it usually means "nothing to change". Second, WordPress doesn't check that the post exists, so validate the ID yourself or you'll leave orphaned rows in wp_postmeta:

if ( ! get_post( $post_id ) ) {
    return; // avoid orphaned meta
}
$result = update_post_meta( $post_id, 'color', 'blue' );
if ( false === $result && get_post_meta( $post_id, 'color', true ) !== 'blue' ) {
    error_log( 'Saving color failed' ); // a real failure
}

How do I use update_post_meta()? (examples)

Save a custom field on save_post (secure version)

add_action( 'save_post_product', function ( $post_id ) {
    if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) return;
    if ( wp_is_post_revision( $post_id ) ) return;
    if ( ! isset( $_POST['iw_price_nonce'] ) || ! wp_verify_nonce( $_POST['iw_price_nonce'], 'iw_save_price' ) ) return;
    if ( ! current_user_can( 'edit_post', $post_id ) ) return;

    if ( isset( $_POST['iw_price'] ) ) {
        $price = sanitize_text_field( wp_unslash( $_POST['iw_price'] ) );
        update_post_meta( $post_id, '_iw_price', $price );
    }
} );

$_POST data is already slashed by WordPress, so wp_unslash() it before sanitizing. update_post_meta() then strips slashes again, so plain text values come out right. See the slashing section below for the case where this goes wrong.

Read the value back

$price = get_post_meta( $post_id, '_iw_price', true ); // single value (string)

Change a page template

update_post_meta( $page_id, '_wp_page_template', 'templates/full-width.php' );

Increment a counter

$views = (int) get_post_meta( $post_id, 'iw_views', true );
update_post_meta( $post_id, 'iw_views', $views + 1 );

Under heavy traffic, two requests can read the same number and both write +1. For accurate counters, use a custom table or an atomic SQL UPDATE.

How does the $prev_value parameter work?

Real output showing add_post_meta multiple values and the update_post_meta prev_value parameter

A post can hold several rows with the same key (added with add_post_meta()). $prev_value picks which one to change:

Call (rows: happy, sad)ReturnRows afterwards
update_post_meta( $p, 'mood', 'excited', 'sad' )truehappy, excited
update_post_meta( $p, 'mood', 'calm', 'nope' )falseunchanged (no match)
update_post_meta( $p, 'mood', 'calm' )truecalm, calm (every row overwritten)

That last row is the classic bug: calling update_post_meta() without $prev_value on a multi-value key silently destroys the other values.

Unique vs multiple values: add_post_meta() and get_post_meta()

CallReal result
add_post_meta( $p, 'mood', 'happy' )8 (meta ID)
add_post_meta( $p, 'mood', 'sad' )9, so a second row is added
add_post_meta( $p, 'mood', 'x', true )false, because $unique is true and the key exists
get_post_meta( $p, 'mood', true )'happy' (first row only)
get_post_meta( $p, 'mood', false )['happy', 'sad']
get_post_meta( $p, 'missing', true )'' (empty string)
get_post_meta( $p, 'missing', false )[] (empty array)

Because a missing key returns '', you can't tell "empty" from "not set" with get_post_meta() alone. Use metadata_exists( 'post', $post_id, $key ).

What happens when you save an array?

update_post_meta storing an array as a serialized string and scalars returned as strings

WordPress serializes arrays and objects on save and unserializes them on read. The real stored value for [12, 15, 18] is a:3:{i:0;i:12;i:1;i:15;i:2;i:18;}.

  • get_post_meta( $p, 'gallery', true ) returns the array. With false you get it nested one level deeper ([[12, 15, 18]]), which is a common source of "my array has an extra level" bugs.
  • Saving the identical array again returns false. Saving ['12','15','18'] (strings) returns true, because the serialized form differs.
  • Scalars come back as strings: 5 → '5', true → '1', false → '' (and the row still exists).
  • You can't query inside serialized data with meta_query reliably. If you need to filter by a value, store it in its own key.

Why do backslashes disappear? (wp_slash and JSON)

update_post_meta stripping backslashes from JSON and the wp_slash fix

For historical reasons, update_post_meta() expects the key and value to be slashed, and runs stripslashes() on them before saving. Our test results:

You saveYou get back
{"key":"value with \"quotes\""}{"key":"value with "quotes""}, which json_decode() can't parse
wp_slash( $json ){"key":"value with \"quotes\""}, which decodes fine
'C:\Users\demo''C:Usersdemo'
['p' => 'C:\Users']['p' => 'C:Users'] (arrays are stripped too)

The rule is simple: if a value can contain a backslash (JSON, Windows paths, regex), wrap it in wp_slash(). Values from $_POST are the exception, since they arrive slashed. wp_unslash() them for sanitizing, and pass the result straight to update_post_meta() only if it contains no backslashes you need to keep.

What are protected meta keys (underscore prefix)?

Keys starting with _ are "protected": is_protected_meta( '_price', 'post' ) returns true, and is_protected_meta( 'price', 'post' ) returns false. Protected keys are hidden from the classic Custom Fields meta box, so editors can't change them by accident. That's why WooCommerce uses _price and core uses _wp_page_template and _thumbnail_id (see our guide to getting the featured image URL in PHP). Protection is about the UI only. It isn't security, as the REST test below shows.

How do I delete post meta?

Call (rows: a, b)ReturnResult
delete_post_meta( $p, 'tag', 'a' )trueonly "b" remains
delete_post_meta( $p, 'tag' )trueall rows removed
delete_post_meta( $p, 'tag' ) againfalsenothing to delete

To remove a key from every post, use delete_post_meta_by_key( 'tag' ). Don't set a value to '' when you mean "remove", because that leaves a row behind.

How do I update post meta through the REST API?

Updating registered post meta through the WordPress REST API with register_post_meta

Meta only appears in /wp-json/wp/v2/posts/<id> (and in the block editor) after you register it with show_in_rest:

add_action( 'init', function () {
    register_post_meta( 'post', 'iw_price', array(
        'type'              => 'number',
        'single'            => true,
        'show_in_rest'      => true,
        'sanitize_callback' => fn( $value ) => round( (float) $value, 2 ),
        'auth_callback'     => fn() => current_user_can( 'edit_posts' ),
    ) );
    register_post_meta( 'post', 'iw_tags', array(
        'type'         => 'array',
        'single'       => true,
        'show_in_rest' => array( 'schema' => array( 'items' => array( 'type' => 'string' ) ) ),
    ) );
} );

Then send POST /wp-json/wp/v2/posts/4 with {"meta":{"iw_price":19.999,"iw_tags":["a","b"]}}. What we saw:

RequestReal response
iw_price: 19.999, iw_tags: ["a","b"]Saved as 20 and ["a","b"] (the sanitize_callback ran)
iw_price: "lots"rest_invalid_type: "meta.iw_price is not of type number."
An unregistered key (color)Silently ignored, with no error
Anonymous GET on a published postRegistered meta is visible, including _iw_secret
'sanitize_callback' => 'floatval'Fatal error: "floatval() expects exactly 1 argument, 4 given"

Three gotchas here that most tutorials miss:

  • auth_callback protects writes, not reads. Never register a secret with show_in_rest.
  • WordPress passes 4 arguments to sanitize_callback, and strict PHP built-ins like floatval crash. Wrap them in a closure.
  • The sanitize_callback also runs on direct PHP calls. Our update_post_meta( 4, 'iw_price', '7.456abc' ) was stored as '7.46'.

Site-wide settings work the same way through register_setting(). See our WordPress get_option() guide for the REST settings endpoint.

Is update_post_meta() slow? (meta cache and performance)

Measured database queries for get_post_meta, update_post_meta and update_meta_cache

WordPress loads all meta for a post in one query and keeps it in the object cache. We counted $wpdb->num_queries:

OperationDB queries
5× get_post_meta() on one post (cold cache)1
Other keys on the same post afterwards0
update_post_meta() with a changed value3
update_post_meta() with the same value1
get_post_meta() in a loop over 20 posts20
The same loop after update_meta_cache( 'post', $ids )1
  • When you loop over IDs you fetched yourself, prime the cache with update_meta_cache( 'post', $ids ). WP_Query does this for you by default (update_post_meta_cache).
  • Each changed update_post_meta() clears that post's meta cache. In bulk imports, compare values first or batch your writes.
  • wp_postmeta has no index on meta_value, so meta_query filters on large sites get slow. Use a taxonomy or a custom table for data you filter on heavily.
  • On a case-insensitive collation (the default, utf8mb4_unicode_520_ci), update_post_meta( $p, 'color2', 'Y' ) updated our existing Color2 row, but get_post_meta( $p, 'color2', true ) returned ''. Keep keys lowercase.

Which hooks fire when post meta is updated?

HookWhen
update_post_metadata (filter)Before saving. Return anything other than null to short-circuit. In our test, returning false blocked the write.
add_post_meta / added_post_metaBefore and after a new row is inserted (on the first update_post_meta() call for a key)
update_post_meta / updated_post_metaBefore and after an existing row changes. Doesn't fire when the value is unchanged.
sanitize_post_meta_{$meta_key}Sanitizes the value (this is what register_post_meta's sanitize_callback hooks into)

update_post_meta vs add_post_meta vs update_metadata

FunctionUse it when
update_post_meta()One value per key: add or overwrite
add_post_meta()Several values per key, or "only add if missing" ($unique = true)
delete_post_meta()Remove one value or the whole key
update_metadata()The same logic for any object type ('post', 'user', 'term', 'comment')
update_user_meta() / update_term_meta()The equivalents for users and terms
wp_insert_post( [ 'meta_input' => [...] ] )Setting meta while creating or updating a post in one call
update_option()Site-wide settings, not per post (see get_option())

What are the most common update_post_meta mistakes?

  1. Treating false as failure. An unchanged value also returns false.
  2. Overwriting multi-value keys. Leaving out $prev_value overwrites every row.
  3. Breaking JSON. Values with backslashes need wp_slash().
  4. Using get_post_meta( ..., false ) for an array. It's nested one level deeper, so use true.
  5. Skipping nonce and capability checks on save_post, and not skipping autosaves.
  6. Saving meta for post IDs that don't exist. WordPress adds the orphan row without complaint.
  7. Thinking _keys are private. They're only hidden from the meta box, and REST can still expose them.
  8. Running get_post_meta() in a loop without priming the meta cache.
  9. Using built-in PHP functions as sanitize_callback, which causes an ArgumentCountError fatal.

Need the current page's slug or title before saving meta for it? See how to get the current page name in WordPress.

FAQs

The new meta ID (int) if the key didn't exist, true if an existing value changed, and false on failure or when the new value equals the stored one.

Most often because the value is identical to what's stored (including 5 vs "5"). It also returns false for a post ID of 0, an empty key, a $prev_value that doesn't match, or a filter that blocks the write.

Yes. It behaves like add_post_meta() and returns the new meta ID.

No. In our test, update_post_meta( 999999, 'x', 'y' ) returned a meta ID for a post that doesn't exist. Check with get_post() first.

Pass the current value as the fourth argument: update_post_meta( $id, 'mood', 'excited', 'sad' ).

Yes. WordPress serializes it automatically. Read it back with get_post_meta( $id, $key, true ).

update_post_meta() runs stripslashes() on input. Wrap the value in wp_slash() to keep backslashes in JSON or file paths.

update_post_meta() adds or overwrites. add_post_meta() always adds a new row, unless $unique is true and the key already exists.

Register the key with register_post_meta() and show_in_rest => true, then send {"meta":{"key":"value"}} to /wp-json/wp/v2/posts/<id> as an authenticated user. Unregistered keys are ignored.

If you pass a revision ID, the meta is saved on the parent post. In our test, the revision itself got nothing.

Not for one post. All its meta loads in one query and is then cached. Across many posts, prime the cache with update_meta_cache(), or let WP_Query do it.

Conclusion

update_post_meta() is simple to call but easy to misuse. Treat false as "maybe unchanged", use $prev_value for multi-value keys, wp_slash() anything with backslashes, register keys before using them over REST, and prime the meta cache in loops. If you'd rather have a team build custom fields, meta boxes or a full plugin for you, our WordPress plugin development service can help.

Share this article

Filed under Wordpress

Need help with this?

Talk to the Infyways team.

From audits and upgrades to performance and long-term care, we build and maintain the platforms our clients ship on.