Himanshu SharmaSoftware engineer
Let’s talk

WordPress · Lesson 33 of 45

Custom fields and post meta

Store extra data on posts with add_post_meta(), get_post_meta() and update_post_meta(), register meta for the REST API, add a meta box, and query by meta.

15 minHooks and pluginsUpdated 23 October 2026

By the end of this lessonAdd fields such as cooking time to a custom post type, save them safely from a meta box, show them, and filter posts by their values.

Extra data for a post

Titles and content are not enough for a recipe: it also has a cooking time, a serving count, perhaps a source link. WordPress stores such extra values as post meta (also called custom fields) in the wp_postmeta table: one row per value, with the post ID, a key and a value.

This lesson continues the Bakery Recipes plugin from Custom post types.

Reading and writing meta

php
<?php
$id = wp_insert_post( [ 'post_type' => 'recipe', 'post_title' => 'Sourdough loaf', 'post_status' => 'publish' ] );

add_post_meta( $id, '_cooking_minutes', 45 );
echo "minutes: ", get_post_meta( $id, '_cooking_minutes', true ), "\n";

update_post_meta( $id, '_cooking_minutes', 50 );    // changes it (or adds it if missing)
echo "minutes: ", get_post_meta( $id, '_cooking_minutes', true ), "\n";

update_post_meta( $id, '_servings', 8 );
print_r( get_post_meta( $id ) );   // every meta key of the post

delete_post_meta( $id, '_servings' );
var_dump( get_post_meta( $id, '_servings', true ) );
result
minutes: 45
minutes: 50
Array
(
    [_cooking_minutes] => Array
        (
            [0] => 50
        )

    [_servings] => Array
        (
            [0] => 8
        )

)
string(0) ""
  • get_post_meta( $id, $key, true ) returns one value. Without true you get an array, because a key may be stored several times.
  • update_post_meta() is the one you want most of the time: it adds the row if the key is missing and changes it otherwise.
  • Values are stored as text. Arrays and objects are serialised for you, but keep structured data small and consider a taxonomy or a custom table for big sets.
  • Keys that start with an underscore are private: WordPress's built-in Custom Fields box hides them, so only your own interface edits them.

Register the meta

register_post_meta() tells WordPress what a key holds. It sets the type, a sanitising function, and, with show_in_rest, makes the value available to the block editor and the REST API:

php
<?php
add_action( 'init', function () {
	register_post_meta( 'recipe', '_cooking_minutes', [
		'type'              => 'integer',
		'single'            => true,
		'default'           => 0,
		'sanitize_callback' => 'absint',
		'auth_callback'     => fn() => current_user_can( 'edit_posts' ),
		'show_in_rest'      => true,
	] );
} );
php
<?php
require_once WP_PLUGIN_DIR . '/bakery-recipes/meta.php';
php
<?php
$id = wp_insert_post( [ 'post_type' => 'recipe', 'post_title' => 'Rye bread', 'post_status' => 'publish' ] );
update_post_meta( $id, '_cooking_minutes', '-30 minutes' );   // sanitised by absint
var_dump( get_post_meta( $id, '_cooking_minutes', true ) );
result
string(2) "30"

The sanitise callback ran when the value was saved: absint() turned the text into a whole number and dropped the minus sign, so the stored value is 30. The REST API now returns it as an integer under the meta field. In the browser the editor can read and write it through JavaScript (see Creating a custom block).

A meta box to edit the field

In the classic editor and for private fields, you add a meta box with add_meta_box(). The box has two jobs: show the field, and, when the post is saved, check the request and store the sanitised value.

php
<?php
add_action( 'add_meta_boxes', function () {
	add_meta_box( 'recipe_details', 'Recipe details', 'bakery_recipe_box', 'recipe', 'side' );
} );

function bakery_recipe_box( WP_Post $post ) {
	wp_nonce_field( 'bakery_save_recipe', 'bakery_recipe_nonce' );
	$minutes = (int) get_post_meta( $post->ID, '_cooking_minutes', true );
	?>
	<p>
		<label for="cooking_minutes">Cooking time (minutes)</label>
		<input type="number" id="cooking_minutes" name="cooking_minutes" min="0" value="<?php echo esc_attr( $minutes ); ?>">
	</p>
	<?php
}

add_action( 'save_post_recipe', function ( int $post_id ) {
	// 1. Not an automatic save or a revision.
	if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
		return;
	}
	// 2. The request came from our form.
	if ( ! isset( $_POST['bakery_recipe_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['bakery_recipe_nonce'] ) ), 'bakery_save_recipe' ) ) {
		return;
	}
	// 3. The user is allowed to edit this post.
	if ( ! current_user_can( 'edit_post', $post_id ) ) {
		return;
	}
	// 4. Sanitise, then save.
	if ( isset( $_POST['cooking_minutes'] ) ) {
		update_post_meta( $post_id, '_cooking_minutes', absint( wp_unslash( $_POST['cooking_minutes'] ) ) );
	}
} );

Those four steps are the pattern for every save handler in WordPress: autosave check, nonce, capability, sanitise. Skipping them is the most common way plugins become insecure; Writing secure WordPress code explains why each one matters. Meta boxes are shown with $_POST values, so use wp_unslash() before sanitising, because WordPress adds slashes to request data.

Showing the value

In a template or shortcode, read it and escape it:

php
<?php
add_shortcode( 'cooking_time', function ( $atts ) {
	$id = get_the_ID();
	$minutes = (int) get_post_meta( $id, '_cooking_minutes', true );
	return $minutes ? sprintf( '<p class="time">Ready in %d minutes</p>', $minutes ) : '';
} );
php
<?php
require_once WP_PLUGIN_DIR . '/bakery-recipes/display.php';
php
<?php
$id = wp_insert_post( [ 'post_type' => 'recipe', 'post_title' => 'Focaccia', 'post_status' => 'publish', 'post_content' => '[cooking_time]' ] );
update_post_meta( $id, '_cooking_minutes', 35 );
http
GET /recipes/focaccia/
result
Ready in 35 minutes

Querying by meta

meta_query finds and sorts posts by meta values. Use meta_value_num and a NUMERIC type so that "9" is not sorted after "45":

php
<?php
foreach ( [ 'Quick roll' => 20, 'Long loaf' => 120, 'Medium bun' => 45 ] as $title => $minutes ) {
	$id = wp_insert_post( [ 'post_type' => 'recipe', 'post_title' => $title, 'post_status' => 'publish' ] );
	update_post_meta( $id, '_cooking_minutes', $minutes );
}

$query = new WP_Query( [
	'post_type'     => 'recipe',
	'no_found_rows' => true,
	'meta_key'      => '_cooking_minutes',
	'orderby'       => 'meta_value_num',
	'order'         => 'ASC',
	'meta_query'    => [ [ 'key' => '_cooking_minutes', 'value' => 60, 'compare' => '<=', 'type' => 'NUMERIC' ] ],
] );
foreach ( $query->posts as $post ) {
	echo $post->post_title, ' - ', get_post_meta( $post->ID, '_cooking_minutes', true ), " min\n";
}
result
Quick roll - 20 min
Rye bread - 30 min
Focaccia - 35 min
Medium bun - 45 min
Sourdough loaf - 50 min

Meta queries are convenient, but wp_postmeta has no index on values, so filtering a large site on a meta value is slow. For values you filter by often, a taxonomy is better.

Common mistakes

  • Saving without nonce and capability checks.
  • Printing meta without escaping (esc_html(), esc_attr()).
  • Forgetting true in get_post_meta() and then treating the array as a value.
  • Using meta for what should be a taxonomy, or for large lists.
  • Sorting numbers as text (use meta_value_num).

Exercise

Register a _servings integer meta for recipes, with default 4, and print how many servings a recipe has when it was never set.

Show one possible answer
php
add_action( 'init', function () {
	register_post_meta( 'recipe', '_servings', [
		'type'              => 'integer',
		'single'            => true,
		'default'           => 4,
		'sanitize_callback' => 'absint',
		'show_in_rest'      => true,
	] );
} );

echo get_post_meta( $recipe_id, '_servings', true );   // 4, the registered default

With single true, get_post_meta() returns the registered default when nothing is stored.

Key points

  • Post meta stores extra key/value data for a post in the wp_postmeta table.
  • get_post_meta(), update_post_meta(), add_post_meta() and delete_post_meta() read and write it; register_post_meta() describes its type and exposes it to the REST API.
  • A meta box in the editor needs a nonce, a capability check and sanitisation when saving.
  • meta_query in WP_Query filters and sorts by meta values; keys that start with an underscore are hidden from the Custom Fields box.

Check yourself

1. Where is post meta stored?
2. What does the underscore in the key _cooking_minutes do?
3. What must a save handler for a meta box check first?
4. What does get_post_meta( $id, 'key', true ) return?