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.
- Published
- Updated
- Reading time
- 9 min read

On this page 16 sections
- What are the parameters of update_post_meta()?
- What does update_post_meta() return?
- How do I use update_post_meta()? (examples)
- How does the $prev_value parameter work?
- Unique vs multiple values: add_post_meta() and get_post_meta()
- What happens when you save an array?
- Why do backslashes disappear? (wp_slash and JSON)
- What are protected meta keys (underscore prefix)?
- How do I delete post meta?
- How do I update post meta through the REST API?
- Is update_post_meta() slow? (meta cache and performance)
- Which hooks fire when post meta is updated?
- update_post_meta vs add_post_meta vs update_metadata
- What are the most common update_post_meta mistakes?
- FAQs
- Conclusion
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,
truewhen it changes a value, andfalseon failure or when the value is unchanged. - Without
$prev_valueit 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
| Parameter | Type | Notes |
|---|---|---|
$post_id | int | Required. If you pass a revision ID, WordPress saves the meta on the parent post instead. |
$meta_key | string | Required. Start it with _ to hide it from the Custom Fields box. |
$meta_value | mixed | Required. Arrays and objects are serialized. Expected to be slashed. |
$prev_value | mixed | Optional. 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?

| Situation (tested) | Return |
|---|---|
| Key didn't exist, so a row was added | Meta ID, e.g. 5 |
| Key existed and the value changed | true |
| Same value as stored | false |
Integer 5 stored, then string "5" saved | false (treated as unchanged) |
$post_id of 0, or an empty $meta_key | false |
| A post ID that doesn't exist (999999) | Meta ID: the row is still added |
A filter on update_post_metadata short-circuits it | Whatever 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?

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) | Return | Rows afterwards |
|---|---|---|
update_post_meta( $p, 'mood', 'excited', 'sad' ) | true | happy, excited |
update_post_meta( $p, 'mood', 'calm', 'nope' ) | false | unchanged (no match) |
update_post_meta( $p, 'mood', 'calm' ) | true | calm, 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()
| Call | Real 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?

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. Withfalseyou 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) returnstrue, 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_queryreliably. If you need to filter by a value, store it in its own key.
Why do backslashes disappear? (wp_slash and JSON)

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 save | You 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) | Return | Result |
|---|---|---|
delete_post_meta( $p, 'tag', 'a' ) | true | only "b" remains |
delete_post_meta( $p, 'tag' ) | true | all rows removed |
delete_post_meta( $p, 'tag' ) again | false | nothing 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?

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:
| Request | Real 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 post | Registered 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_callbackprotects writes, not reads. Never register a secret withshow_in_rest.- WordPress passes 4 arguments to
sanitize_callback, and strict PHP built-ins likefloatvalcrash. Wrap them in a closure. - The
sanitize_callbackalso runs on direct PHP calls. Ourupdate_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)

WordPress loads all meta for a post in one query and keeps it in the object cache. We counted $wpdb->num_queries:
| Operation | DB queries |
|---|---|
5× get_post_meta() on one post (cold cache) | 1 |
| Other keys on the same post afterwards | 0 |
update_post_meta() with a changed value | 3 |
update_post_meta() with the same value | 1 |
get_post_meta() in a loop over 20 posts | 20 |
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_Querydoes 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_postmetahas no index onmeta_value, someta_queryfilters 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 existingColor2row, butget_post_meta( $p, 'color2', true )returned''. Keep keys lowercase.
Which hooks fire when post meta is updated?
| Hook | When |
|---|---|
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_meta | Before and after a new row is inserted (on the first update_post_meta() call for a key) |
update_post_meta / updated_post_meta | Before 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
| Function | Use 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?
- Treating
falseas failure. An unchanged value also returnsfalse. - Overwriting multi-value keys. Leaving out
$prev_valueoverwrites every row. - Breaking JSON. Values with backslashes need
wp_slash(). - Using
get_post_meta( ..., false )for an array. It's nested one level deeper, so usetrue. - Skipping nonce and capability checks on
save_post, and not skipping autosaves. - Saving meta for post IDs that don't exist. WordPress adds the orphan row without complaint.
- Thinking
_keysare private. They're only hidden from the meta box, and REST can still expose them. - Running
get_post_meta()in a loop without priming the meta cache. - 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
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.
Filed under Wordpress


