WordPress user meta is where custom information about a user belongs when it is not part of the core account record. A plugin might store an employee extension, an onboarding flag, or a display preference there. The API looks simple, but its return types and duplicate-value behavior can surprise code that treats it like an ordinary PHP array.
This guide covers the four user-meta CRUD functions, then builds a secure custom field on the WordPress profile screen. It also shows how to register that field for the REST API deliberately, when a no-code Pods field is reasonable, and where multisite changes the storage decision.
The goal is a field that behaves predictably, survives an audit, and does not write to the database every time somebody opens a front-end page. That last part should not be a high bar.

What WordPress user meta actually stores
The WordPress Plugin Handbook guide to user metadata explains the basic model: the core users table keeps essential account fields, while the usermeta table holds additional key-value data in a one-to-many relationship. [1] A single user can therefore have many keys, and one key can have more than one row.
That last detail matters. A setting such as a phone extension normally needs one value. A repeatable item, such as several certification IDs, may legitimately need several rows under the same key. Choose that shape before picking the write function.
The get_user_meta() reference documents another important boundary: non-serialized scalar values come back as strings. Stored false becomes '', stored true becomes '1', and numbers become numeric strings. Arrays and objects are serialized and return as their original type. [2]
With $single set to true, both a missing key and a stored empty string return ''. If existence changes the decision, use metadata_exists( 'user', $user_id, $key ). Do not guess from empty().
Set $single to false when the key is intentionally repeatable. Leaving the key blank retrieves the user’s entire metadata array, including fields the current feature may not need. That can complicate privacy review and make display code depend on unrelated plugins, so request one prefixed key whenever possible.
WordPress user meta CRUD without the traps
| Function | Use it when | Important behavior |
|---|---|---|
get_user_meta() | You need one value, all values for a key, or all keys for a user. | true for $single returns one value; false returns an array. |
add_user_meta() | You deliberately want another row, or want to refuse an add when the key exists. | Duplicates are allowed by default. Pass true as $unique to reject another row for the key. [3] |
update_user_meta() | You maintain an ordinary one-value setting. | It adds a missing key. Without $prev_value, it updates all matching rows; with it, only matching values. [4] |
delete_user_meta() | You remove a key or one matching value. | Key-only deletion removes all rows for that user/key. A third value argument preserves other duplicates. [5] |
For most profile settings, use update_user_meta(). Its return value needs care: false can mean the update failed, but it can also mean the submitted value already matched the stored value. An unchanged preference is not an emergency.
Use add_user_meta() when duplicates are part of the data model. Its $unique = true option asks WordPress not to add the key when that key already exists, but the reference does not promise a concurrency-safe lock. For a normal one-value setting, update_user_meta() communicates the intent more clearly.
Finally, never test a retrieved flag with === true. A safe exact check for a value stored as boolean true looks like this:
update_user_meta( $user_id, 'wpshout_verified', true );
$is_verified = '1' === get_user_meta(
$user_id,
'wpshout_verified',
true
);Code language: PHP (php)
Add a secure custom field to user profiles
The profile screen needs two display hooks and two save hooks because a user may edit their own account or an authorized administrator may edit somebody else. Before saving, current_user_can() should check the edit_user meta capability against the target user ID, not a role name. [6]
A custom wp_nonce_field() ties the form to a named action. [7] On save, check_admin_referer() verifies that request intent. [8] The nonce does not authorize the actor, so the capability check stays.
<?php
add_action( 'show_user_profile', 'wpshout_show_phone_extension' );
add_action( 'edit_user_profile', 'wpshout_show_phone_extension' );
function wpshout_show_phone_extension( $user ) {
if ( ! current_user_can( 'edit_user', $user->ID ) ) {
return;
}
$extension = get_user_meta(
$user->ID,
'wpshout_phone_extension',
true
);
wp_nonce_field(
'wpshout_save_phone_extension_' . $user->ID,
'wpshout_phone_extension_nonce'
);
?>
<h2>Contact routing</h2>
<table class="form-table" role="presentation">
<tr>
<th>
<label for="wpshout_phone_extension">
Phone extension
</label>
</th>
<td>
<input
type="text"
id="wpshout_phone_extension"
name="wpshout_phone_extension"
value="<?php echo esc_attr( $extension ); ?>"
class="regular-text"
/>
</td>
</tr>
</table>
<?php
}
add_action( 'personal_options_update', 'wpshout_save_phone_extension' );
add_action( 'edit_user_profile_update', 'wpshout_save_phone_extension' );
function wpshout_save_phone_extension( $user_id ) {
if ( ! current_user_can( 'edit_user', $user_id ) ) {
return;
}
check_admin_referer(
'wpshout_save_phone_extension_' . $user_id,
'wpshout_phone_extension_nonce'
);
if ( ! isset( $_POST['wpshout_phone_extension'] ) ) {
return;
}
$extension = sanitize_text_field(
wp_unslash( $_POST['wpshout_phone_extension'] )
);
if ( '' === $extension ) {
delete_user_meta( $user_id, 'wpshout_phone_extension' );
return;
}
update_user_meta(
$user_id,
'wpshout_phone_extension',
$extension
);
}Code language: HTML, XML (xml)
Place code like this in a small site-specific plugin and test it on staging. The submitted text goes through wp_unslash() and sanitize_text_field(). The saved value passes through esc_attr() when it returns to the input’s HTML attribute. Clearing the field deletes the key instead of storing another ambiguous empty string.

Register user meta for REST only when needed
Custom user meta is not automatically a public API field. If an authenticated application genuinely needs it, register_meta() can describe the key’s type, cardinality, sanitation, authorization, and REST visibility. [9]
add_action( 'init', function () {
register_meta(
'user',
'wpshout_phone_extension',
array(
'type' => 'string',
'single' => true,
'sanitize_callback' => 'sanitize_text_field',
'auth_callback' => function (
$allowed,
$meta_key,
$object_id
) {
return current_user_can( 'edit_user', $object_id );
},
'show_in_rest' => true,
)
);
} );Code language: PHP (php)
With show_in_rest enabled, the registered key can appear under .meta in relevant REST responses, subject to endpoint and authorization rules. The REST API Handbook’s response-extension guide recommends registered meta for existing metadata, while register_rest_field() is the route for arbitrary or derived data with custom callbacks and schema on rest_api_init. [10]
Do not set show_in_rest merely because it is available. Password material, private HR data, tokens, and other sensitive values do not belong in this example or in broadly exposed user meta. Start from the consumers that need the field, then grant the narrowest read and write access that works.
The authorization callback above checks the target user each time. If the client only needs a derived label, expose that derived value through a purpose-built field instead of making the underlying record writable.
Optional: add user fields with Pods
If the site needs an admin-managed field rather than custom plugin code, Pods can extend the WordPress Users object with custom fields. [11]
The Pods user-directory tutorial also covers validation and access decisions. [12] That can be convenient, but it does not remove the need to decide who may see or change the data.
Multisite, privacy, and performance boundaries
On multisite, ordinary user meta belongs to the shared user identity. A preference that should vary by site belongs in get_user_option() and update_user_option(). WordPress checks the site-prefixed value first, and updates are site-specific by default. [13]
When code checks a capability in another site’s context, use current_user_can_for_site() with that site ID and the relevant capability. [14] Do not copy role names into meta and treat them as authorization.
Store only data with a defined purpose and retention plan. User meta can appear in backups, exports, admin tools, or an API if code exposes it. Avoid secrets and oversized blobs, prefix custom keys to prevent collisions, and fetch only the keys the request needs. A convenient table is still a database, not a junk drawer.

A practical user-meta checklist
- Choose one-value or repeatable semantics before choosing add or update.
- Expect scalar strings on retrieval, especially
''and'1'. - Use
metadata_exists()when empty and missing mean different things. - Authorize against the target user, verify the nonce, sanitize input, and escape output.
- Register REST visibility only for a known consumer and non-sensitive field.
- Use per-site user options for multisite preferences.
- Test create, unchanged update, changed update, clearing, and unauthorized submission.
User meta works well when its shape and boundaries are explicit. Keep one-value settings simple, use duplicate rows only on purpose, and treat the profile form as an authorized write path rather than a convenient place to trust input. The API is small. The decisions around it are the real feature.
References
- [1] WordPress Plugin Handbook: Working with User Metadata.
- [2] WordPress Developer Resources: get_user_meta().
- [3] WordPress Developer Resources: add_user_meta().
- [4] WordPress Developer Resources: update_user_meta().
- [5] WordPress Developer Resources: delete_user_meta().
- [6] WordPress Developer Resources: current_user_can().
- [7] WordPress Developer Resources: wp_nonce_field().
- [8] WordPress Developer Resources: check_admin_referer().
- [9] WordPress Developer Resources: register_meta().
- [10] WordPress REST API Handbook: Modifying Responses.
- [11] Pods Docs: Extending Users.
- [12] Pods Docs: Add Fields to Users.
- [13] WordPress Developer Resources: get_user_option().
- [14] WordPress Developer Resources: current_user_can_for_site().
How to Speed Up Your WordPress Site
With some simple fixes, you can reduce your loading times by even 50-80% 🚀
By entering your email above, you're subscribing to our weekly newsletter. You can change your mind at any time. We respect your inbox and privacy.








Thank you for nice article. I have experienced this:
If I use
add_user_meta(get_current_user_id(),’video_history’,”fero”);
and save it into single.php, oen a any blog post, I see adding one value “fero” into video_history user meta, which is working as expected.
If you open another post you save two values of “fero” into meta array.
Do you thing it is bug?
Hi,
I have over 1600 user meta items that I need to insert once, as part of a data migration. I have tried to implement this as part of the register_activation_hook in a site plugin. My thinking being that when I activate the plugin, the user meta gets inserted, and then I can deactivate and delete the plugin. Future maintenance is to be handled by a third-party plugin. But I can’t make it work.
My code is below. At a quick look, can you see anything wrong with it, or is there a better way to call it than from the plugin activation?
class essaUserMeta
{
final public function __construct()
{
register_activation_hook( __FILE__, array(‘essaUserMeta’,’user_meta_statements’) );
}
#
# CLASS METHODS
public static function user_meta_statements()
{
update_user_meta( 302, ‘mobile_phone_number’, ’07nnn nnnnnn’ );
# plus 1600+ other statements generated from the database
}
} # end class essaUserMeta
?>
Thanks, Anita. At a glance, that code looks like it should do what you think.
A quick-and-dirty alternative is to register a function that listens for a query string:
Then just go to
yoursite.com/wp-admin/?iwanttodosomethingto run the code.In either case, I’d suggest lots of
var_dumps to see if your code is actually running, and go from there.Hope that helps!
Thanks Fred, I’ll have a play with it over the weekend.
My php code often ends up being a mixture of php, PL/SQL and Java syntax!
Anita
Thanks for your help. My code was not actually being called. I went the route you suggested and once I’d actually registered the function it ran without problem.
I learned something I didn’t know before and I built my first plugin. I am on my way to becoming a journeyman developer. thank you.
Think you using the wrong “update_post_meta( $user_id, $meta_key, $meta_value, $prev_value );” in your documentations, shouldnt it be “update_user_meta()” under – ADDING OR CHANGING WORDPRESS USER META DATA: UPDATE_USER_META()
Sorry I am wrong…
Thanks so much! I’ve fixed that typo.
Thanks for the nice tutorial. I need to use this in my wordpress site that is using divi and formidable forms.
I need to add a numeric field to the each user meta data so that I can use it to count how many entries they have made before they start paying. I will test the above but how can I get_user_meta() per user-id in order to get the value of the new numeric field I added and then add 1 to it and then do the update?
I will really appreciate your help