Post Meta (Custom Fields)

Reading and writing post meta, single versus array returns, and registering meta so it appears in the REST API.

Syntaxget_post_meta( int $post_id, string $meta_key = '', bool $single = false ): mixed

Post 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

Example · php
<?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 ); // array

When 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 ).

Example · php
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.

Example · php
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.

Example · php
// 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

  • Be the first to comment on this lesson.