
How to Add Custom Fields to WordPress with ACF?
Advanced Custom Fields (ACF) turns any post, page, or custom post type into a structured content model with its own text, image, repeater, and relationship fields, all editable through a clean admin UI without writing a single meta box by hand. WordPress's built-in Custom Fields meta box works for one-off key/value pairs, but it doesn't validate input, doesn't support field types like image pickers or repeaters, and gives editors no guidance about what's expected. ACF fixes all of that.
Under the hood, ACF still stores its data as ordinary post meta in the wp_postmeta table (with a matching _field_name reference row pointing at the field's key), so anything you already know about get_post_meta() still applies; ACF just gives you a much better admin experience and read API layered on top.
This guide covers installing ACF, building a field group through the UI, syncing that field group as version-controlled acf-json, registering fields entirely in PHP with acf_add_local_field_group(), and the read/write functions (get_field(), the_field(), update_field()) you'll use constantly once fields exist.
Step 1: Install ACF
The free version, Advanced Custom Fields, is available directly from the WordPress plugin directory:
wp plugin install advanced-custom-fields --activate
The paid ACF PRO tier adds Repeater, Flexible Content, Gallery, and Options Page field types, and is worth it the moment a project needs a repeatable field group (like a list of FAQs on a page) or a site-wide Theme Options screen.
Step 2: Build a Field Group Through the UI
In wp-admin, go to Custom Fields → Add New to create a field group. A "Product Details" field group with a price, a SKU, and a gallery, attached to the product post type:
| Field Label | Field Name | Field Type |
|---|---|---|
| Price | price | Number |
| SKU | sku | Text |
| In Stock | in_stock | True / False |
| Gallery | gallery | Gallery (PRO) |
In the Location rules box at the bottom of the screen, set the rule to Post Type is equal to Product so this field group only appears on Product editor screens, not on regular posts or pages.
Step 3: Read Field Values in a Template
Once fields exist and a product has values saved, the_field() echoes a value directly, and get_field() returns it so you can use it in logic first:
<h1><?php the_title(); ?></h1>
<?php if ( get_field( 'in_stock' ) ) : ?>
<span class="badge badge--in-stock">In Stock</span>
<?php else : ?>
<span class="badge badge--out-of-stock">Out of Stock</span>
<?php endif; ?>
<p class="price">$<?php the_field( 'price' ); ?></p>
<p class="sku">SKU: <?php the_field( 'sku' ); ?></p>
Both functions default to the current post in the loop, but accept an explicit post ID as a second argument, which matters the moment you're pulling field data for a post other than the one currently being rendered, for example inside a "Related Products" widget:
$related_price = get_field( 'price', $related_product_id );
Step 4: Write Field Values Programmatically
update_field() sets a value in code, useful for import scripts, scheduled jobs, or saving data from a custom front-end form:
update_field( 'price', 129.99, $product_id );
update_field( 'in_stock', true, $product_id );
Under the hood this is a thin wrapper that also handles formatting for complex field types (Repeater, Gallery), so prefer it over calling update_post_meta() directly for any field managed by ACF; using the raw meta function can save data in a shape ACF's admin UI doesn't expect.
Step 5: Repeater and Gallery Fields (ACF PRO)
Repeater fields return an array of associative arrays, one per row, so looping over them is straightforward once you know the shape:
<?php if ( have_rows( 'specifications' ) ) : ?>
<table class="spec-table">
<?php while ( have_rows( 'specifications' ) ) : the_row(); ?>
<tr>
<th><?php the_sub_field( 'label' ); ?></th>
<td><?php the_sub_field( 'value' ); ?></td>
</tr>
<?php endwhile; ?>
</table>
<?php endif; ?>
have_rows() / the_row() / the_sub_field() are the Repeater-specific equivalents of a normal while loop; forgetting the_row() inside the loop is the most common Repeater bug, since without it the "current row" pointer never advances and the_sub_field() keeps returning the first row's values forever.
A Gallery field returns an array of full attachment arrays (not just IDs), which is enough to render directly:
<?php $images = get_field( 'gallery' ); ?>
<?php if ( $images ) : ?>
<div class="product-gallery">
<?php foreach ( $images as $image ) : ?>
<img src="<?php echo esc_url( $image['sizes']['medium'] ); ?>"
alt="<?php echo esc_attr( $image['alt'] ); ?>" />
<?php endforeach; ?>
</div>
<?php endif; ?>
Step 6: Register Field Groups in PHP with acf_add_local_field_group()
Building field groups through the UI is fine for a one-off site, but for anything under version control, defining fields in PHP means they deploy with the code and never drift between environments. acf_add_local_field_group(), hooked to acf/init, registers a field group entirely programmatically:
add_action( 'acf/init', function () {
if ( ! function_exists( 'acf_add_local_field_group' ) ) {
return;
}
acf_add_local_field_group( [
'key' => 'group_product_details',
'title' => 'Product Details',
'fields' => [
[
'key' => 'field_product_price',
'label' => 'Price',
'name' => 'price',
'type' => 'number',
'min' => 0,
'step' => 0.01,
],
[
'key' => 'field_product_sku',
'label' => 'SKU',
'name' => 'sku',
'type' => 'text',
'required' => 1,
],
[
'key' => 'field_product_in_stock',
'label' => 'In Stock',
'name' => 'in_stock',
'type' => 'true_false',
'default_value' => 1,
'ui' => 1,
],
[
'key' => 'field_product_specifications',
'label' => 'Specifications',
'name' => 'specifications',
'type' => 'repeater',
'layout' => 'table',
'sub_fields' => [
[
'key' => 'field_spec_label',
'label' => 'Label',
'name' => 'label',
'type' => 'text',
],
[
'key' => 'field_spec_value',
'label' => 'Value',
'name' => 'value',
'type' => 'text',
],
],
],
],
'location' => [
[
[
'param' => 'post_type',
'operator' => '==',
'value' => 'product',
],
],
],
] );
} );
Every field and the group itself needs a unique key (conventionally prefixed group_ and field_), which is how ACF tracks a field's identity independent of its label or name, allowing you to rename a field's label later without losing its stored data.
Step 7: Sync Fields as Version-Controlled acf-json
If you build field groups through the UI instead, ACF can still export them to version control automatically via local JSON. Create an acf-json folder in your theme or plugin root, and tell ACF where to save and load from it:
add_filter( 'acf/settings/save_json', function () {
return get_stylesheet_directory() . '/acf-json';
} );
add_filter( 'acf/settings/load_json', function ( $paths ) {
unset( $paths[0] );
$paths[] = get_stylesheet_directory() . '/acf-json';
return $paths;
} );
Once this is in place, every time you save a field group in wp-admin, ACF also writes a matching group_product_details.json file into that folder automatically. Commit that file to Git, and any other environment that pulls the code gets the identical field group without anyone touching the UI, ACF detects the JSON on load and keeps the database and file in sync, showing a "Sync available" notice if they ever drift apart.
Step 8: Options Pages (ACF PRO)
For site-wide settings that don't belong to any single post, like a footer phone number or a global disclaimer, an ACF Options Page avoids inventing a fake post just to hold theme-wide fields:
if ( function_exists( 'acf_add_options_page' ) ) {
acf_add_options_page( [
'page_title' => 'Theme Settings',
'menu_title' => 'Theme Settings',
'menu_slug' => 'theme-settings',
'capability' => 'manage_options',
'redirect' => false,
] );
}
Reading a value from an options page just omits the post ID argument, or passes 'option' explicitly:
$phone = get_field( 'support_phone', 'option' );
Step 9: Conditional Logic and Field Validation
ACF fields support conditional logic directly in the field group editor, showing or hiding a field based on another field's value, useful for something like only showing a "Sale Price" field once a "On Sale" checkbox is ticked. In acf_add_local_field_group() form, this is the conditional_logic key on the field array:
[
'key' => 'field_product_sale_price',
'label' => 'Sale Price',
'name' => 'sale_price',
'type' => 'number',
'conditional_logic' => [
[
[
'field' => 'field_product_on_sale',
'operator' => '==',
'value' => '1',
],
],
],
],
For validation beyond a field type's built-in constraints (a min/max on a Number field, for instance), the acf/validate_value/name={field_name} filter runs before a value is saved and can reject it with a custom error message shown right in the editor:
add_filter( 'acf/validate_value/name=sku', function ( $valid, $value, $field, $input_name ) {
if ( ! $valid ) {
return $valid;
}
if ( $value && ! preg_match( '/^[A-Z0-9\-]+$/', $value ) ) {
return 'SKU must contain only uppercase letters, numbers, and hyphens.';
}
return $valid;
}, 10, 4 );
Returning a string instead of true is what tells ACF the value failed validation and blocks the save, showing that exact string as the error message next to the field, without needing a separate admin notice or JavaScript alert.
Step 10: Modify Field Values on the Fly with acf/load_value and acf/update_value
Two filters let you transform data as it moves in and out of the database without changing how the field is displayed in the admin UI:
// Normalize a SKU to uppercase before it's saved, regardless of how the editor typed it.
add_filter( 'acf/update_value/name=sku', function ( $value ) {
return is_string( $value ) ? strtoupper( trim( $value ) ) : $value;
}, 10, 1 );
// Apply a default currency formatting when a price field is read back, without changing the stored raw number.
add_filter( 'acf/load_value/name=price', function ( $value, $post_id, $field ) {
return $value; // return the raw value; format only at output time in the template, not here
}, 10, 3 );
It's worth resisting the temptation to format a value for display inside acf/load_value, since that filter also runs when ACF loads the value back into its own edit form, formatting it there (adding a currency symbol, for instance) would corrupt what an editor sees and re-saves in wp-admin. Formatting belongs at the point you output the value in a template, as shown in Step 3, not in a filter that touches both the admin form and the front end.
Frequently Asked Questions (FAQ) About ACF Custom Fields
As standard WordPress post meta in the wp_postmeta table, in a row keyed by the field's name, with a paired row prefixed with an underscore (for example _price) that stores the field's key, which is how ACF's admin UI and read functions know how to render and format the value correctly.
Use get_field() for anything managed by ACF. It applies the field type's formatting logic (returning a boolean for a True/False field, an array for a Repeater or Gallery, and so on) automatically, whereas get_post_meta() returns the raw, unformatted value stored in the database, which for complex field types isn't directly usable without extra parsing.
The free version covers most basic field types (text, number, image, true/false, select, and so on). ACF PRO adds Repeater, Flexible Content, Gallery, Clone, and Options Pages, along with the ability to package field groups as their own ACF Blocks for the block editor.
Registering fields in PHP means the field group definition lives in your codebase and deploys with your code through Git, rather than needing to be manually recreated (or exported and imported) on every environment. It's the standard approach for any site with a development, staging, and production environment.
The post meta rows already saved in the database aren't deleted automatically; only the field group definition (and therefore the admin UI for that field) disappears. The old meta values become orphaned but harmless data unless something else in your code is still reading them directly with get_post_meta().
Not by default, ACF field values aren't included in the standard /wp-json/wp/v2/posts response automatically. You need to either register the field to REST manually with register_rest_field(), or install the free "ACF to REST API" companion plugin, which adds an acf object to REST responses automatically.
Yes, for most real-world content modeling. Native block bindings work well for simple, single-value connections between a field and a block's content, but ACF's Repeater, Flexible Content, and Options Pages still cover structured, multi-value, and site-wide data needs that core doesn't replicate on its own.
Conclusion
ACF's real value isn't the field types themselves, WordPress could technically do most of this with raw post meta and a hand-built meta box, it's the combination of a polished editor experience, sensible read/write functions, and a version-controllable definition format that keeps field groups in sync across environments. Start with the UI to prototype a field group quickly, then either export it to acf-json or rewrite it as acf_add_local_field_group() once it's stable, so it ships as part of your codebase rather than living only in one site's database.
Combine ACF fields with a custom post type and a custom taxonomy, and you have a complete, structured content model that non-technical editors can use confidently without ever seeing raw meta keys.
Here are a few additional resources if you want to go deeper:
- Advanced Custom Fields Documentation — the full field type and function reference.
- ACF: acf_add_local_field_group() — the official guide to registering fields in PHP.
- ACF: Local JSON — how the acf-json sync mechanism works in detail.


