Custom fields, also called post meta, are how WordPress stores arbitrary key/value data tied to a post. Plugins like Advanced Custom Fields, WooCommerce, and Yoast SEO all use it under the hood. get_post_meta() is the function you call to read those values back out.
This guide walks through the function from end to end: the parameters (especially $single, which trips most people up), how to use it inside and outside The Loop, real examples for ACF and WooCommerce, performance tips, and how to expose post meta through the REST API.
The function signature
get_post_meta( int $post_id, string $key = '', bool $single = false )
$post_id(required) — the ID of the post you want meta from$key(optional) — the meta key. Pass an empty string to get every meta value on the post$single(optional, defaultfalse) — return a single value as a string (true), or every value for that key as an array (false)
Return value depends on the inputs: a string if $single=true, an array if $single=false or no key is given, and an empty string or empty array if no meta exists for that key.
Method 1: Get a single meta value
The most common use case. Pass true as the third argument to get a string back instead of an array:
$subtitle = get_post_meta( get_the_ID(), 'subtitle', true );
if ( ! empty( $subtitle ) ) {
echo '<p class="subtitle">' . esc_html( $subtitle ) . '</p>';
}
Always escape on output. esc_html() for plain text, esc_attr() for HTML attributes, esc_url() for URLs.
Method 2: Get all values for a meta key (array)
WordPress lets you store multiple values under the same meta key. To retrieve all of them, pass false (or omit the third argument, since false is the default):
$tags = get_post_meta( get_the_ID(), 'related_skus', false );
foreach ( $tags as $sku ) {
echo esc_html( $sku ) . '<br>';
}
Use this when you genuinely have multiple values for one key. For 99% of cases (single-value fields), Method 1 is what you want.
Method 3: Get every meta value on a post
Pass an empty string as the key, and you get back an associative array of every meta key on the post:
$all_meta = get_post_meta( get_the_ID() );
foreach ( $all_meta as $key => $values ) {
echo $key . ' → ' . print_r( $values, true ) . '<br>';
}
Each value comes back as an array even when there’s only one entry, because Method 3 ignores the $single argument. Useful for debugging, exporting, or building admin tools that show every field on a post.
Inside The Loop vs. outside The Loop
Inside The Loop, get_the_ID() returns the current post automatically:
// In a single.php template, content-loop, etc.
$value = get_post_meta( get_the_ID(), 'my_field', true );
Outside The Loop (sidebars, custom queries, REST endpoints, AJAX handlers), pass the ID explicitly:
$value = get_post_meta( 42, 'my_field', true );
// Or from a $post object
global $post;
$value = get_post_meta( $post->ID, 'my_field', true );
The $single parameter, explained
This is the part that confuses everyone. $single tells WordPress whether to wrap the result in an array.
$single = true→ returns the first stored value as a string. If the meta is itself a serialized array, it gets unserialized for you.$single = false(default) → returns an array of every stored value for that key. Even if there’s only one value, you get a one-element array.
Forgetting true is the #1 reason developers get unexpected arrays back. Default behavior is geared toward the multi-value case, which most fields don’t actually use.
Real-world examples
Reading an ACF field
ACF stores most field types in post meta. get_field() is the recommended way to read ACF fields because it formats the value (returns a real image array, a real Date object, etc.). But get_post_meta() still works on the raw value:
// Reads the raw stored value (e.g. attachment ID for an image field)
$image_id = get_post_meta( get_the_ID(), 'hero_image', true );
echo wp_get_attachment_image( $image_id, 'large' );
// ACF's preferred function gives you a formatted array
$image = get_field( 'hero_image' );
echo wp_get_attachment_image( $image['id'], 'large' );
Reading WooCommerce product meta
WooCommerce stores everything (price, SKU, stock, dimensions) in post meta with underscored keys:
$product_id = get_the_ID();
$price = get_post_meta( $product_id, '_price', true );
$sku = get_post_meta( $product_id, '_sku', true );
$stock = get_post_meta( $product_id, '_stock', true );
The leading underscore marks meta as protected (hidden from the Custom Fields admin UI). For most cases, prefer the WooCommerce API: wc_get_product( $id )->get_price(). The product object handles edge cases like sale prices and currency conversion that raw meta doesn’t.
A simple post-views counter
function smartwp_track_views( $post_id ) {
if ( ! is_single() ) return;
$views = (int) get_post_meta( $post_id, 'post_views', true );
update_post_meta( $post_id, 'post_views', $views + 1 );
}
add_action( 'wp_head', function () {
smartwp_track_views( get_the_ID() );
} );
// Display the count
$views = (int) get_post_meta( get_the_ID(), 'post_views', true );
echo number_format( $views ) . ' views';
The (int) cast handles the case where meta hasn’t been set yet (empty string casts to 0).
Reading Yoast SEO meta
$seo_title = get_post_meta( get_the_ID(), '_yoast_wpseo_title', true );
$meta_desc = get_post_meta( get_the_ID(), '_yoast_wpseo_metadesc', true );
$focus_keyword = get_post_meta( get_the_ID(), '_yoast_wpseo_focuskw', true );
Same pattern works for Rank Math (rank_math_title, rank_math_description) and other SEO plugins.
Default values when meta is missing
get_post_meta() returns an empty string when the key doesn’t exist (with $single=true), or an empty array (without it). It does not return null or false, which catches a lot of developers writing strict comparisons.
// Safest pattern with PHP 7+
$layout = get_post_meta( get_the_ID(), 'layout', true ) ?: 'sidebar-right';
// Or check explicitly
$value = get_post_meta( get_the_ID(), 'my_field', true );
if ( $value === '' ) {
$value = 'fallback';
}
Updating, adding, and deleting meta
Three companion functions handle writes:
// Update or create
update_post_meta( $post_id, 'my_field', 'new value' );
// Append (allows duplicate values for the same key)
add_post_meta( $post_id, 'my_field', 'extra value' );
// Delete
delete_post_meta( $post_id, 'my_field' );
// Delete a specific value (when there are duplicates)
delete_post_meta( $post_id, 'my_field', 'extra value' );
update_post_meta() handles the create-or-update case automatically. Use add_post_meta() only when you intentionally want multiple values under the same key. If you’re creating posts from scratch, see how to insert posts programmatically; the same $postarr['meta_input'] shortcut handles meta in one call.
Querying posts by meta value
get_post_meta() reads from a single post. To find posts that match a meta condition, use WP_Query with the meta_query parameter (full guide: how to use meta_query in WordPress):
$query = new WP_Query( [
'post_type' => 'product',
'meta_query' => [
[
'key' => '_price',
'value' => 50,
'compare' => '>',
'type' => 'NUMERIC',
],
],
] );
Performance tips
WordPress caches meta automatically. The first call to get_post_meta() for a given post fetches every meta row in one query and stores it in the object cache. All subsequent calls for that post hit the cache, not the database.
Two things to watch:
- Custom queries with
'no_found_rows' => trueand'update_post_meta_cache' => falseskip the meta-cache priming step. If you then loop through results callingget_post_meta(), you get N+1 queries. Either leave the cache priming on, or pre-fetch withupdate_postmeta_cache( $ids ). - Heavy meta-key counts (hundreds per post) can blow up the cache. Profile with Query Monitor before assuming the default behavior is fine.
From the command line, WP-CLI handles meta directly with wp post meta get, wp post meta update, and wp post meta delete. Useful for one-off cleanup or scripted migrations.
Exposing post meta via the REST API
By default, custom meta isn’t returned by the WordPress REST API. To expose a meta key, register it with register_post_meta():
add_action( 'init', function () {
register_post_meta( 'post', 'subtitle', [
'show_in_rest' => true,
'single' => true,
'type' => 'string',
'auth_callback' => function () {
return current_user_can( 'edit_posts' );
},
] );
} );
After that, the meta value appears in the meta object on every post response and can be updated via POST /wp-json/wp/v2/posts/{id} with {"meta": {"subtitle": "..."}}. This is the same pattern used to expose Yoast SEO fields, ACF (with the right settings), and any other plugin meta to REST clients.
Frequently asked questions
What’s the difference between get_post_meta() and get_post_custom()?
get_post_custom() always returns every meta value on a post as an array of arrays (every value wrapped in an array). It’s a thin wrapper around get_post_meta( $id ) with no key. get_post_meta() is more flexible because you can ask for a single key and a single string value. For new code, use get_post_meta().
Why am I getting an array when I expected a string?
You forgot the third argument. Pass true to get a string back: get_post_meta( $id, 'my_field', true ). Without it, the function returns every stored value for that key wrapped in an array, even when there’s only one entry.
Does get_post_meta() work with Advanced Custom Fields?
Yes, but with a caveat. ACF stores most fields as raw post meta, so get_post_meta() reads the underlying value. The downside is you lose ACF’s value formatting (image fields come back as IDs instead of arrays, date fields as raw strings, etc.). For ACF projects, get_field() is the cleaner choice. get_post_meta() is the right tool when you don’t have ACF loaded.
How do I get post meta from a different post?
Pass the other post’s ID as the first argument: get_post_meta( 42, 'my_field', true ). The function doesn’t care whether the ID belongs to the current post, a different post, an attachment, a custom post type, or any other object stored in wp_posts. As long as the ID exists, the meta lookup works.
What’s the difference between meta_key and meta_value in the database?
Post meta lives in the wp_postmeta table with four columns: meta_id, post_id, meta_key, and meta_value. The $key argument to get_post_meta() matches the meta_key column. Multiple rows can share the same post_id + meta_key combination, which is why the $single parameter exists.
Wrap-up
Pass true as the third argument unless you genuinely have multiple values stored under one key. Use get_the_ID() inside The Loop and an explicit ID anywhere else. Reach for get_field() when ACF is loaded and you want formatted values, and get_post_meta() when you want the raw stored data. Pair it with update_post_meta(), add_post_meta(), and delete_post_meta() to cover the full read/write lifecycle, or with register_post_meta() to expose a key through the REST API.


