You can work with custom fields without a plugin. Reading takes one line of get_post_meta().
<?php $price = get_post_meta(get_the_ID(), 'price', true); ?>
<?php if ($price !== '') : ?>
<p class="price">$<?php echo esc_html(number_format((int) $price)); ?></p>
<?php endif; ?>The true in the third argument is the first hurdle. Understand that and the rest is variations.
What the third argument (single) means
Post meta is built so that the same key can hold several values, which gives it two ways to return things.
get_post_meta(get_the_ID(), 'price', true); // '3300' (the value itself)
get_post_meta(get_the_ID(), 'price', false); // ['3300'] (an array)
get_post_meta(get_the_ID(), 'price'); // omitted behaves like false = an arrayYou normally want one value, so add true. Forgetting it and seeing Array on the page is the classic stumble.
Omit the key too and you get every meta value on that post as an array. Handy for debugging.
<?php var_dump(get_post_meta(get_the_ID())); ?>Revealing the input panel (the standard custom fields panel)
In the block editor, the standard custom fields panel is hidden at first.
- Open ”⋮” at the top right of the editing screen → “Preferences”
- Turn on “Custom fields” under “Panels” (the screen reloads)
- A “Custom Fields” panel appears below the editor
- Use “Add New” to enter name
priceand value3300
To use this panel on a custom post type, include 'custom-fields' in supports when calling register_post_type().
Saving, updating and deleting
These three are how you write values from code.
update_post_meta($post_id, 'price', 3300); // add if missing, update if present (use this by default)
add_post_meta($post_id, 'tag', 'sale'); // add another value under the same key (becomes multi-valued)
delete_post_meta($post_id, 'price'); // deleteWhen in doubt, update_post_meta(). Since it’s “update if present, add if not,” you never get accidental duplicates.
A note on using values as numbers
Post meta values are stored as strings, without distinguishing types. Pin down the type when you use them for arithmetic or comparison.
<?php $price = (int) get_post_meta(get_the_ID(), 'price', true); ?>
<p>Including tax: $<?php echo esc_html(number_format((int) round($price * 1.1))); ?></p>Filtering or sorting a list by price needs a type specified for the same reason.
TipsFiltering and sorting by custom field with meta_queryHow to filter and sort with meta_queryThe trap when you want to display “0”
<?php if (get_post_meta(get_the_ID(), 'stock', true)) : ?> <!-- ❌ 0 is treated as "not there" -->
<?php if (get_post_meta(get_the_ID(), 'stock', true) !== '') : ?> <!-- ✅ -->On a site where you want to show zero stock or a price of zero, compare against an empty string instead. In PHP, both 0 and '0' are falsy in a condition.
Keys starting with an underscore
Meta keys starting with _ (like _thumbnail_id) are treated as protected meta and don’t appear in the standard custom fields panel.
- Values you want people to edit on screen → ordinary names like
price - Internal values for code only → underscore-prefixed names like
_my_internal_flag
The internal data WordPress and plugins use is stored in this form too.
Register it if you’ll use the block editor or the REST API
To read and write meta from the block editor’s sidebar or through the REST API, declare it with register_post_meta().
add_action('init', function () {
register_post_meta('shohin', 'price', [
'type' => 'number',
'single' => true,
'show_in_rest' => true,
'auth_callback' => function () {
return current_user_can('edit_posts');
},
]);
});Setting show_in_rest makes the value usable from the block editor (the JavaScript side) as well.
How this relates to ACF (SCF)
ACF stores things as the same post meta internally, so get_post_meta() can read them. There are differences, though.
get_post_meta() | get_field() (ACF/SCF) | |
|---|---|---|
| What comes back | The raw stored value | A value formatted according to its type |
| Image field | The ID as a string | ID, URL or array, depending on the setting |
| Repeaters and groups | Raw row counts and internal keys | A usable array |
If ACF is installed, get_field() is the reliable choice. get_post_meta() earns its keep when you’re building a plugin-free setup, or a theme that doesn’t break when the plugin is deactivated.
Building your own input panel (add_meta_box)
The standard “name and value” panel is hard for the people running the site to understand. To build a dedicated input, use add_meta_box().
add_action('add_meta_boxes', function () {
add_meta_box('shohin_price', 'Price', function ($post) {
$value = get_post_meta($post->ID, 'price', true);
wp_nonce_field('shohin_price_save', 'shohin_price_nonce');
echo '<input type="number" name="price" value="' . esc_attr($value) . '">';
}, 'shohin', 'side');
});
add_action('save_post_shohin', function ($post_id) {
if (!isset($_POST['shohin_price_nonce'])
|| !wp_verify_nonce($_POST['shohin_price_nonce'], 'shohin_price_save')) {
return;
}
if (!current_user_can('edit_post', $post_id)) {
return;
}
update_post_meta($post_id, 'price', (int) ($_POST['price'] ?? 0));
});The save routine must include nonce verification and a capability check. Skip those and you’ve left a hole that lets values be written from outside.
TipsChoosing the right escaping function (esc_html, esc_attr, esc_url)Choosing between escaping functions, and how nonces fit inSummary
- Read with
get_post_meta($id, 'key', true). Thetruesays “take it as a single value” - Write with
update_post_meta()by default (adds if missing, updates if present) - Values are stored as strings, so pin numbers down with
(int). To display0, compare against an empty string - If ACF is installed,
get_field()is reliable. Core features suit plugin-independent themes
Once you know core can handle this, “we can’t build it without a plugin” stops being a thing.