WordPress guide

How to Create an ACF Field Group

Create Advanced Custom Fields field groups through the admin UI, PHP or Local JSON, then output the values safely and keep them under version control.

Updated · 9 min read

Advanced Custom Fields (ACF) is the most common way to add structured data to WordPress content: an event date, a product spec sheet, a team member's job title. Fields are organised into field groups, and each group has location rules that decide which edit screens it appears on.

This guide covers the three ways to define a field group, the details that trip people up (keys, names and return formats), and how to print values on the front end without opening security holes.

Three ways to define a field group

MethodWhere it livesBest for
Admin UIDatabase (acf-field-group and acf-field posts)Quick builds, non-developers, small sites
Local JSONacf-json/ folder in your theme or plugin, synced with the UITeams using Git who still want the visual editor
PHPacf_add_local_field_group() in codePlugins, locked-down client sites, fields that must not be edited in the admin

1. The admin UI

Go to ACF → Field Groups → Add New, give the group a title, add fields, set location rules and publish. It is the fastest route, but the definition lives only in the database. Moving it between local, staging and production means exporting and importing, and nothing records who changed what.

2. Local JSON (recommended for most projects)

Create an empty folder named acf-json in your active theme. From then on, every time you save a field group in the admin, ACF writes a JSON file such as group_6512ab34cd567.json into that folder. Commit the folder to Git. On another environment, ACF reads the JSON instead of querying the database, and the Field Groups screen shows a Sync available notice when the file is newer than the database copy.

To keep the JSON in a plugin rather than a theme, point ACF to a custom folder:

<?php
add_filter( 'acf/settings/save_json', function () {
    return plugin_dir_path( __FILE__ ) . 'acf-json';
} );

add_filter( 'acf/settings/load_json', function ( $paths ) {
    $paths[] = plugin_dir_path( __FILE__ ) . 'acf-json';
    return $paths;
} );

You can write or tweak these files by hand, but they must be valid JSON. The ACF JSON generator produces a ready-to-sync file, and the JSON formatter helps when you need to review a diff.

3. PHP registration

Field groups registered in PHP are “local”: they appear on edit screens but not in the Field Groups list, so they cannot be changed from the admin. Register them on the acf/include_fields hook (or acf/init on older versions):

<?php
add_action( 'acf/include_fields', function () {
    if ( ! function_exists( 'acf_add_local_field_group' ) ) {
        return;
    }

    acf_add_local_field_group( array(
        'key'      => 'group_event_details',
        'title'    => 'Event Details',
        'fields'   => array(
            array(
                'key'            => 'field_event_date',
                'label'          => 'Event date',
                'name'           => 'event_date',
                'type'           => 'date_picker',
                'display_format' => 'j F Y',
                'return_format'  => 'Ymd',
                'required'       => 1,
            ),
            array(
                'key'   => 'field_event_venue',
                'label' => 'Venue',
                'name'  => 'event_venue',
                'type'  => 'text',
            ),
            array(
                'key'           => 'field_event_ticket_url',
                'label'         => 'Ticket URL',
                'name'          => 'event_ticket_url',
                'type'          => 'url',
            ),
        ),
        'location' => array(
            array(
                array(
                    'param'    => 'post_type',
                    'operator' => '==',
                    'value'    => 'acme_event',
                ),
            ),
        ),
        'position'        => 'acf_after_title',
        'show_in_rest'    => 1,
    ) );
} );

The ACF field generator outputs this array structure for you, with unique keys and sensible defaults for each field type.

Field keys vs field names

Every field has two identifiers, and mixing them up causes most “my value won't save” bugs.

  • Name (event_date) is the meta key in wp_postmeta. It is what you pass to get_field() in templates. Names need only be unique within the group's context.
  • Key (field_event_date) must be globally unique and start with field_. ACF stores it in a second, underscore-prefixed meta row (_event_date) to know which field definition the value belongs to.

When you save values programmatically with update_field() before any value exists, use the key. Using the name works for simple text fields but breaks complex types such as repeaters and relationships, because ACF can't find the field definition.

Location rules

Location rules are an array of OR groups, each containing AND conditions. The outer array means “any of these”; the inner arrays mean “all of these”. Common parameters include post_type, page_template, post_taxonomy, options_page, taxonomy (term edit screens), user_form and block. Keep rules as narrow as possible: a field group shown on every post type adds clutter and extra meta writes everywhere.

Field types and return formats

Several field types let you choose what get_field() returns. Picking the right one saves code in every template.

Field typeReturn format optionsTypical choice
ImageArray, URL, IDID, then wp_get_attachment_image() for srcset and lazy loading
FileArray, URL, IDArray (gives URL, filename, size)
LinkArray, URLArray (URL, title, target)
Post Object / RelationshipPost object, IDID for performance, objects for convenience
Date PickerAny PHP date formatStore as Ymd so it sorts correctly in queries
Select / Checkbox / RadioValue, Label, BothValue (labels can change; values should not)
True / FalseBooleanUse the stylised UI toggle for clarity

Displaying fields safely

get_field() returns raw data. Anyone with edit access can type HTML or JavaScript into a text field, so escape on output according to the context. Note that ACF's the_field() now escapes its output with wp_kses_post by default (introduced in ACF 6.2.5), but explicit escaping with get_field() keeps your intent obvious:

<?php
$venue      = get_field( 'event_venue' );
$ticket_url = get_field( 'event_ticket_url' );
$details    = get_field( 'event_details' ); // WYSIWYG field
$image_id   = get_field( 'event_image' );   // return format: ID
$date_raw   = get_field( 'event_date' );    // Ymd, e.g. 20261015

if ( $venue ) : ?>
    <p class="event-venue"><?php echo esc_html( $venue ); ?></p>
<?php endif;

if ( $ticket_url ) : ?>
    <a class="button" href="<?php echo esc_url( $ticket_url ); ?>">Buy tickets</a>
<?php endif;

if ( $details ) {
    echo wp_kses_post( $details );
}

if ( $image_id ) {
    echo wp_get_attachment_image( $image_id, 'large' );
}

if ( $date_raw ) {
    $date = DateTime::createFromFormat( 'Ymd', $date_raw );
    echo '<time datetime="' . esc_attr( $date->format( 'Y-m-d' ) ) . '">'
        . esc_html( wp_date( 'j F Y', $date->getTimestamp() ) ) . '</time>';
}
  • esc_html() for plain text in HTML content.
  • esc_attr() for values inside HTML attributes.
  • esc_url() for anything used in href or src.
  • wp_kses_post() for rich text from WYSIWYG fields that should keep safe HTML.

Always guard with an if: empty fields return null, false or an empty string depending on type, and printing wrappers for empty values leaves stray markup.

Repeaters, groups and flexible content

The Group field is available in the free version and bundles sub-fields under one name; it returns an associative array. Repeater, Flexible Content, Gallery and Clone require ACF PRO.

<?php
// Group field (free): returns an array of sub-field values.
$organiser = get_field( 'organiser' );
if ( $organiser ) {
    echo esc_html( $organiser['name'] );
}

// Repeater (PRO): loop rows with have_rows().
if ( have_rows( 'schedule' ) ) : ?>
    <ul class="schedule">
    <?php while ( have_rows( 'schedule' ) ) : the_row(); ?>
        <li>
            <strong><?php echo esc_html( get_sub_field( 'time' ) ); ?></strong>
            <?php echo esc_html( get_sub_field( 'session' ) ); ?>
        </li>
    <?php endwhile; ?>
    </ul>
<?php endif;

Repeaters store each sub-field of each row as its own meta row (schedule_0_time, schedule_0_session, schedule_1_time…). That makes them flexible but awkward to query. If you need to filter content by a value, keep it in a top-level field or a taxonomy instead.

Version control workflow

  1. Create the acf-json folder and commit it.
  2. Edit field groups only on your local environment.
  3. Commit the JSON changes with the template code that uses them.
  4. After deploying, open ACF → Field Groups on the target site and sync any groups flagged as changed — or keep the JSON as the only source and never edit groups in production.
  5. Never edit the same group on two environments at once; the last JSON written wins.

Performance tips

  • Local JSON or PHP groups skip the database lookups for field definitions, which is measurably faster on screens with many groups.
  • Use get_fields() sparingly; it loads every field for the post. Fetch the fields you need.
  • Prefer ID return formats for relationship and post object fields on archive pages; loading full post objects for every row adds queries.
  • For meta queries (meta_query or orderby => meta_value), keep the value simple and consistently formatted — Ymd dates, plain numbers — so comparisons work.
  • Turn on show_in_rest only for groups the REST API or a headless front end actually consumes.

Putting it together

A typical build registers a custom post type with the CPT generator, attaches a field group to it via a post_type location rule, stores the definition in Local JSON, and outputs each value with the right escaping function. That combination gives editors a clean interface and gives developers a reviewable, deployable schema.