TechEarl

ACF Fields vs Native Post Meta in WordPress

ACF and native post meta both write to the same wp_postmeta table. Here is what register_post_meta gives you, what ACF adds on top, and the read/write rules so a bulk script and a content editor never fight over the same field.

Ishan Karunaratne⏱️ 8 min readUpdated
Share thisCopied
Comparing Advanced Custom Fields with native WordPress post meta, and how both store data in the same wp_postmeta table

ACF and native post meta are not two different storage systems. They both write to the same wp_postmeta table, one row per key. The choice between them is not "where does the data live" (it lives in the same place), it is "who needs to edit it, and how much structure do you want around it." Get that distinction right and a bulk script and a human editor can share the same field without corrupting each other's work. Get it wrong and you end up with an ACF field whose value a script overwrote out from under it, so the admin shows one thing and the front end another.

Here is what native post meta gives you, what Advanced Custom Fields adds on top, and the read/write rules that keep the two in sync.

Native post meta: the raw key-value store

Every post can have any number of meta rows: a key, a value, and the post ID. You write and read them with two functions, no setup required:

php
update_post_meta( 42, 'price', 19.99 );
$price = get_post_meta( 42, 'price', true ); // true = return the single value, not an array

That is the whole API. The value is stored as a string (WordPress serializes arrays and objects automatically), there is no type, no validation, and no admin UI. Out of the box these show up in the bland Custom Fields metabox as a flat key/value pair, if you have it enabled at all.

Since WordPress 4.6 you can do better than an anonymous key with register_post_meta. It does not add a UI, but it declares the meta to WordPress, so it gets a type, optional sanitization, and (with show_in_rest) an entry in the REST API:

php
add_action( 'init', 'te_register_product_meta' );

function te_register_product_meta(): void {
	register_post_meta( 'post', 'price', array(
		'type'              => 'number',
		'single'            => true,
		'show_in_rest'      => true,
		'sanitize_callback' => function ( $value ) {
			return round( (float) $value, 2 );
		},
		'auth_callback'     => function () {
			return current_user_can( 'edit_posts' );
		},
	) );
}

This is the right tool when the field is written by code, not by hand: a derived value, an import target, a flag your plugin sets. It is self-documenting, it sanitizes on the way in, and it is REST-addressable. What it is not is friendly to a content editor, there is still no field to fill in on the post screen.

ACF: a UI and a type system on top of the same table

Advanced Custom Fields solves the missing-UI problem. You define a field group in the admin (or in code), attach it to a post type, and ACF renders a proper editing interface on the post screen: text inputs, selects, date pickers, image pickers, repeaters and flexible content in the Pro version. The editor sees labelled fields, not a raw key/value box.

The ACF field group editor in WordPress admin, defining a Product Details group with a Price number field attached to the post type
An ACF field group: a labelled Price field attached to posts. This is the editing UI native meta never gives you.

You write and read ACF fields with update_field and get_field:

php
update_field( 'price', 19.99, 42 );
$price = get_field( 'price', 42 );

The important part, and the thing that trips people up, is where ACF puts that value. It writes to wp_postmeta, exactly like native meta. For the field above it writes two rows:

  • price19.99 (the value)
  • _pricefield_abc123 (a reference to the field-group definition)

That underscore-prefixed twin is how ACF knows which field definition a value belongs to, which is what powers return-format conversion (a date stored as Ymd but returned as a DateTime, a relationship stored as an ID but returned as a WP_Post). You can confirm it from the command line:

A terminal running wp post meta list, showing both a price row and a hidden underscore price row that references the ACF field key
wp post meta list on an ACF-managed field: the value (price) and ACF's reference twin (_price).

So the _price value, field_te_price, is the interesting part. It is not a label, it is a key, and that key is itself a row in the database. ACF stores every field definition as its own post in wp_posts with post_type = acf-field, where post_name is the key and the field's whole configuration is serialized into post_content. Resolve field_te_price and then unserialize that post_content and you are looking at the actual definition the value is bound to:

Two queries against wp_posts for field_te_price: the first resolves it to post ID 5011, an acf-field titled Price with parent 5010; the second selects post_content and pipes it through php unserialize and jq, printing the field definition as JSON with type number, instructions Retail price in USD, required false, and prepend dollar sign
field_te_price is post 5011 (an acf-field, child of group 5010). Its post_content is the serialized field definition; unserialized through jq it is plain config: type number, the instructions, prepend $.

That is the full chain, all of it real rows: the value (price = 24.95) carries a reference (_price = field_te_price), and the reference resolves to a post whose post_content is the field's definition, the type, the prepend, the instructions, the return format. That same definition is what get_field reads to turn a stored Ymd string into a DateTime, or a stored ID into a WP_Post; get_post_meta skips the lookup entirely, which is exactly why it hands back the raw stored value while get_field hands back the formatted one. One note on the key: it is readable here (field_te_price) because the field was authored with that key; a field created through the ACF UI gets an auto-generated one like field_64f3a2b1c9d8e. The structure is identical either way.

The read/write rule that keeps them in sync

Because both store in the same table, you can read an ACF value with get_post_meta and you can write one with update_post_meta. Whether you should comes down to one rule:

If a field is managed by ACF, write to it with update_field, not update_post_meta.

Here is why. update_post_meta( 42, 'price', 25 ) changes the price row but leaves the _price reference untouched, which is usually fine for a value that already exists. But if the row does not exist yet, writing only price and not _price gives you a value ACF doesn't recognize as one of its fields, so get_field may not apply the field's return formatting and the admin UI can behave oddly. update_field writes both rows correctly every time. The same asymmetry runs the other way on reads:

  • Reading a raw scalar (a price, a flag) you set yourself: get_post_meta is fine and marginally faster, it is one row lookup with no field-definition processing.
  • Reading anything with a return format (dates, relationships, images, selects, repeaters): use get_field, or you get the raw stored string instead of the object ACF would hand you.

For a bulk script that updates thousands of posts, this is the difference between a clean run and a pile of half-registered fields. If ACF owns the field, call update_field inside the loop. If the field is a plain native meta you registered yourself, update_post_meta is the leaner call.

Which one to reach for

SituationReach for
A human edits the value on the post screenACF
Repeaters, flexible content, relationship/image pickersACF (Pro for repeaters/flexible)
A derived or computed value only code ever writesregister_post_meta
An import or sync target a script populatesregister_post_meta, or update_field if editors also touch it
You need the field in the REST API with a typeregister_post_meta with show_in_rest
A throwaway flag with no UI and no editorplain update_post_meta

The cluster this article belongs to leans on both. The Google Sheet sync writes editor-owned fields, so it goes through update_field; a pure code-to-code backfill uses native meta. Knowing which table row you are actually touching, and who else touches it, is the whole game.

Sources

Authoritative references this article was fact-checked against.

TagsWordPressACFCustom FieldsPost MetaPHP

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

Safely Bulk-Update Custom Fields in WordPress

A bulk custom-field update with no undo is one typo away from wrecking thousands of posts. Here is a safe pattern (and a downloadable WP-CLI command) with a dry run, a backup gate, a change-only changelog, and idempotent writes.