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:
update_post_meta( 42, 'price', 19.99 );
$price = get_post_meta( 42, 'price', true ); // true = return the single value, not an arrayThat 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:
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.

You write and read ACF fields with update_field and get_field:
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:
price→19.99(the value)_price→field_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:

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:

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, notupdate_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_metais 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
| Situation | Reach for |
|---|---|
| A human edits the value on the post screen | ACF |
| Repeaters, flexible content, relationship/image pickers | ACF (Pro for repeaters/flexible) |
| A derived or computed value only code ever writes | register_post_meta |
| An import or sync target a script populates | register_post_meta, or update_field if editors also touch it |
| You need the field in the REST API with a type | register_post_meta with show_in_rest |
| A throwaway flag with no UI and no editor | plain 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.
- register_post_meta() - WordPress Developer Referencedeveloper.wordpress.org
- update_post_meta() - WordPress Developer Referencedeveloper.wordpress.org
- update_field() - ACF Documentationadvancedcustomfields.com
- get_field() - ACF Documentationadvancedcustomfields.com
- register_meta() - WordPress Developer Referencedeveloper.wordpress.org





