Meta Fields
Custom meta fields attach extra structured data to posts, pages, users, terms, or comments. Use register_post_meta for typed, REST-exposed fields that work in the block editor; reach for ACF / Meta Box / Pods only when you outgrow the basics.
register_post_meta, REST, ACF, queries
EXAMPLE
<?php
// 1) Register a post meta field — the modern way (5.5+)
add_action( 'init', function () {
register_post_meta(
'post', // post type ('' = all)
'subtitle',
array(
'type' => 'string',
'description' => 'Subtitle displayed below the title',
'single' => true, // one value, not array
'default' => '',
'show_in_rest' => true, // expose via REST + block editor
'auth_callback' => function () {
return current_user_can( 'edit_posts' );
},
'sanitize_callback' => 'sanitize_text_field',
)
);
} );
// 2) Get / update / delete
$value = get_post_meta( $post_id, 'subtitle', true ); // single
$all = get_post_meta( $post_id, 'subtitle', false ); // array (when single=false)
update_post_meta( $post_id, 'subtitle', 'Modern WordPress development' );
delete_post_meta( $post_id, 'subtitle' );
// 3) REST exposure
// With show_in_rest => true, the field appears in:
// GET /wp-json/wp/v2/posts/{id} → "meta": { "subtitle": "..." }
// POST /wp-json/wp/v2/posts/{id}
// body: { "meta": { "subtitle": "New value" } }
// Block editor automatically supports binding ( useEntityProp ):
import { useEntityProp } from '@wordpress/core-data';
const [ meta, setMeta ] = useEntityProp( 'postType', 'post', 'meta' );
<input value={meta.subtitle ?? ''} onChange={(e) => setMeta({ ...meta, subtitle: e.target.value })} />
// 4) Rich types (object/array)
register_post_meta( 'product', 'specs', array(
'type' => 'object',
'single' => true,
'show_in_rest' => array(
'schema' => array(
'type' => 'object',
'properties' => array(
'weight_g' => array( 'type' => 'integer' ),
'dimensions' => array( 'type' => 'string' ),
'color' => array( 'type' => 'string' ),
),
),
),
) );
update_post_meta( $post_id, 'specs', array(
'weight_g' => 250,
'dimensions' => '20×10×5 cm',
'color' => 'red',
) );
// 5) Query posts by meta
$query = new WP_Query( array(
'post_type' => 'product',
'meta_query' => array(
'relation' => 'AND',
array(
'key' => 'in_stock',
'value' => '1',
'compare' => '=',
),
array(
'key' => 'price_cents',
'value' => array( 1000, 5000 ),
'compare' => 'BETWEEN',
'type' => 'NUMERIC',
),
),
'orderby' => 'meta_value_num',
'meta_key' => 'price_cents',
'order' => 'ASC',
) );
// CAREFUL: meta_query joins are SLOW at scale. Index meta keys with custom DB indexes or move
// high-cardinality fields to a custom table.
// 6) Show in templates
<?php
if ( $subtitle = get_post_meta( get_the_ID(), 'subtitle', true ) ) :
?>
<p class="entry-subtitle"><?php echo esc_html( $subtitle ); ?></p>
<?php endif; ?>
// 7) User meta
register_meta( 'user', 'twitter_handle', array(
'type' => 'string',
'show_in_rest' => true,
'single' => true,
) );
get_user_meta( $user_id, 'twitter_handle', true );
update_user_meta( $user_id, 'twitter_handle', '@example' );
// Display + save in user profile screen:
add_action( 'show_user_profile', 'add_twitter_field' );
add_action( 'edit_user_profile', 'add_twitter_field' );
add_action( 'personal_options_update', 'save_twitter_field' );
add_action( 'edit_user_profile_update', 'save_twitter_field' );
function add_twitter_field( $user ) {
$handle = esc_attr( get_user_meta( $user->ID, 'twitter_handle', true ) );
echo "<table class='form-table'><tr><th><label for='twitter_handle'>Twitter</label></th>
<td><input name='twitter_handle' id='twitter_handle' value='{$handle}' /></td></tr></table>";
}
function save_twitter_field( $user_id ) {
if ( ! current_user_can( 'edit_user', $user_id ) ) return;
update_user_meta( $user_id, 'twitter_handle', sanitize_text_field( $_POST['twitter_handle'] ?? '' ) );
}
// 8) Term meta
register_term_meta( 'category', 'banner_image_id', array(
'type' => 'integer',
'single' => true,
'show_in_rest' => true,
) );
update_term_meta( $term_id, 'banner_image_id', $attachment_id );
$image_id = get_term_meta( $term_id, 'banner_image_id', true );
// 9) Hidden meta (won't show in custom field UI)
register_post_meta( 'post', '_internal_id', array(
'type' => 'string',
'single' => true,
) );
// Prefixing the key with '_' makes it hidden from the legacy Custom Fields metabox.
// 10) ACF / Meta Box / Pods
// Plugins offer richer UI: repeaters, flexible content, conditional fields, REST API integration.
// • ACF Pro — most popular; great UX; commercial license
// • Meta Box — generous free version; developer-friendly
// • Pods — custom post types + content fields with relationships
//
// They all store data in postmeta by default (compatible with WP_Query),
// or in custom tables (ACF Pro option, Meta Box premium) for performance.
// 11) Custom table for high-cardinality meta
// When meta queries dominate your slow log, model the data as a custom table:
// wp_my_products_specs (product_id, weight_g, dimensions, color, …)
// Query via $wpdb->prepare( 'SELECT … WHERE product_id IN (…)' )
// Index the relevant columns; orders of magnitude faster than postmeta joins.
// 12) Validation + sanitisation
register_post_meta( 'post', 'price_cents', array(
'type' => 'integer',
'show_in_rest' => true,
'sanitize_callback' => function ( $v ) {
$v = (int) $v;
return $v < 0 ? 0 : $v;
},
'auth_callback' => function () { return current_user_can( 'edit_posts' ); },
) );
// 13) Common bugs
// • show_in_rest=true but field not appearing in block editor → register on 'init', not 'wp_loaded'
// • Meta queries that JOIN multiple meta tables → exponential slowdown; index or move to custom table
// • Storing big blobs in meta → serialised PHP arrays balloon postmeta; use a custom table
// • Forgetting auth_callback → anonymous REST callers can write meta
// • Slug typos between register + get → silent missing field
// • Storing dates as strings → can't sort or range-query; store as UNIX timestamps or ISO strings
// • Pre-WP 5.5: meta wasn't typed; use register_post_meta with schema for safety
// • Querying with type='NUMERIC' but value is string '0050' → padding may break comparisons
// • ACF + register_post_meta colliding on same key → drift; choose one source
Why it matters
register_post_meta with show_in_rest and an explicit schema is the modern way to add typed structured data — the block editor and REST API just work. Promote to a plugin (ACF, Meta Box) for repeaters and rich UI; promote to a custom table when meta_query joins start dominating your slow query log.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
update_post_meta($post_id, 'isbn', '978-…'); get_post_meta($post_id, 'isbn', true);Try it Yourself »
Discussion
Loading…