Post Meta (Custom Fields)
Reading and writing post meta, single versus array returns, and registering meta so it appears in the REST API.
get_post_meta( int $post_id, string $meta_key = '', bool $single = false ): mixedPost meta is arbitrary key/value data attached to a post, stored one row per value in wp_postmeta — meta_id, post_id, meta_key, meta_value. Because a key may repeat for the same post, every read has to decide whether you want one value or all of them.
The four functions
add_post_meta( $post_id, $key, $value, $unique = false ) returns the new meta ID or false. update_post_meta( $post_id, $key, $value, $prev_value = '' ) inserts if the key is absent, so it is usually the one you want. get_post_meta( $post_id, $key = '', $single = false ) returns a value or an array. delete_post_meta( $post_id, $key, $meta_value = '' ) removes every row for the key, or only rows matching the value.
Single and array
get_post_meta( $id, $key, true ) is shorthand. The longhand it stands for is: read every row for the key, and take the first, falling back to an empty string. With $single false you get an array — empty when nothing is stored, never false unless the post ID is invalid.
Registering it
register_post_meta() (4.9.8) declares a key's type, whether it is single, a sanitize_callback, an auth_callback and show_in_rest. Registered meta appears under meta in the REST response, which is how the block editor reads and writes it.
Meta or taxonomy
meta_value has no index. If you will filter or list posts by a value, make it a taxonomy; keep meta for data you display rather than query.
Example
<?php
$post_id = 42;
// ADD: allows duplicate keys unless $unique is true.
add_post_meta( $post_id, 'acme_isbn', '978-0131103627', true ); // int|false
// UPDATE: inserts when the key is missing, so this is the usual choice.
// Returns the new meta ID, true on update, or false on failure OR no change.
update_post_meta( $post_id, 'acme_isbn', '978-0131103627' );
// READ, shorthand: $single = true gives the value itself,
// or '' when nothing is stored.
$isbn = get_post_meta( $post_id, 'acme_isbn', true );
// READ, longhand: the same result, spelled out.
$values = get_post_meta( $post_id, 'acme_isbn' ); // array of every row
$isbn = $values ? $values[0] : '';
// Repeating keys are the reason arrays are the default return.
add_post_meta( $post_id, 'acme_award', 'Hugo' );
add_post_meta( $post_id, 'acme_award', 'Nebula' );
$awards = get_post_meta( $post_id, 'acme_award' ); // array( 'Hugo', 'Nebula' )
// DELETE one row by value, or every row for the key.
delete_post_meta( $post_id, 'acme_award', 'Hugo' );
delete_post_meta( $post_id, 'acme_isbn' );
// Scalars come back as strings: 12 stored is '12' read. Cast before use.
$page_count = (int) get_post_meta( $post_id, 'acme_pages', true );
// Arrays and objects are serialised on write and unserialised on read,
// keeping their type.
update_post_meta( $post_id, 'acme_dimensions', array( 'w' => 15, 'h' => 23 ) );
$dimensions = get_post_meta( $post_id, 'acme_dimensions', true ); // arrayWhen to use it
- Storing a per-post value such as an ISBN, an event date or an external ID
- Exposing a custom field to the block editor or a headless front end through REST
- Saving a meta box field from $_POST with the nonce and capability checks in place
- Deciding whether a new field should be post meta or a taxonomy term
More examples
Registering meta for the REST API and the block editor
The registration you need before any block-editor sidebar panel, REST client or headless front end can touch a field. The auth_callback is what protects writes — its parameters are ( $allowed, $meta_key, $object_id, $user_id, $cap, $caps ).
add_action( 'init', function () {
register_post_meta(
'book', // '' would register the key for every post type
'acme_isbn', // prefix your keys; a leading underscore hides them
array( // from the classic Custom Fields box
'type' => 'string', // string|boolean|integer|number|array|object
'single' => true,
'default' => '', // 'default' added in 5.5.0
'show_in_rest' => true, // appears under "meta" in the response
'sanitize_callback' => 'sanitize_text_field',
'auth_callback' => function ( $allowed, $meta_key, $post_id ) {
return current_user_can( 'edit_post', $post_id );
},
'revisions_enabled' => true, // 6.4.0+
)
);
// Non-scalar types need an explicit schema or REST will reject them.
register_post_meta( 'book', 'acme_translations', array(
'type' => 'array',
'single' => true,
'show_in_rest' => array(
'schema' => array(
'type' => 'array',
'items' => array( 'type' => 'string' ),
),
),
) );
} );
// The post type must also declare 'custom-fields' support, or the block
// editor will not expose the meta at all.
add_action( 'init', function () {
add_post_type_support( 'book', 'custom-fields' );
}, 20 );Saving a meta box safely
The canonical save handler. The wp_unslash() before sanitising matters: WordPress adds slashes to everything in $_POST, and skipping it stores literal backslashes in the database.
add_action( 'save_post_book', function ( $post_id, $post ) {
// 1. Ignore autosaves and revisions.
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
// 2. Verify the nonce printed by the meta box.
if ( ! isset( $_POST['acme_book_nonce'] )
|| ! wp_verify_nonce( $_POST['acme_book_nonce'], 'acme_save_book' ) ) {
return;
}
// 3. Check the current user may edit THIS post.
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
// 4. Sanitise, then write. An empty submission deletes rather than
// storing an empty string, keeping wp_postmeta free of noise.
if ( empty( $_POST['acme_isbn'] ) ) {
delete_post_meta( $post_id, 'acme_isbn' );
} else {
update_post_meta(
$post_id,
'acme_isbn',
sanitize_text_field( wp_unslash( $_POST['acme_isbn'] ) )
);
}
}, 10, 2 );Meta versus taxonomy, and the cost of querying meta
The decision rule in code. Ask whether anyone will ever list posts by the value: genre, status and region belong in a taxonomy, while an ISBN, a subtitle or a set of coordinates belong in meta.
// Meta: fine for a value you display on the single template.
$isbn = get_post_meta( get_the_ID(), 'acme_isbn', true );
// Meta: workable but unindexed for filtering. wp_postmeta indexes meta_key,
// not meta_value, so this scans every row with that key and joins back.
$cheap = new WP_Query( array(
'post_type' => 'book',
'meta_query' => array(
array(
'key' => 'acme_price',
'value' => 20,
'compare' => '<',
'type' => 'NUMERIC', // meta_value is a text column
),
),
) );
// Taxonomy: the right shape when the value groups posts. Terms live in their
// own indexed tables and get an archive URL for free.
register_taxonomy( 'acme_genre', 'book', array(
'public' => true,
'show_in_rest' => true,
) );
$fantasy = new WP_Query( array(
'post_type' => 'book',
'tax_query' => array(
array( 'taxonomy' => 'acme_genre', 'field' => 'slug', 'terms' => 'fantasy' ),
),
) );
Discussion