How to display custom fields in WordPress: A developer's blueprint

How to display custom fields in WordPress: A developer's blueprint

Last verified: October 14, 2026
25 min read
Guide
Full-stack developer

In WordPress, custom fields are the foundation of structured content. By associating custom metadata with posts, pages, or custom post types, you can extend the platform from a blogging engine into a content management system that handles directory listings, real estate catalogs, and e-commerce inventories. Managing custom fields at scale requires an understanding of the database schema, sanitization on save, escaping on output, and query performance. This guide covers reading and writing post meta, the deprecated the_meta(), ACF, block themes and Block Bindings, query scaling, custom data tables, and REST endpoints.

Learn more about my professional WordPress development services.

#How wp_postmeta stores and serializes custom fields

WordPress uses the Entity-Attribute-Value (EAV) model to store post metadata. In this schema, all metadata is saved in a single table: wp_postmeta.

#wp_postmeta table columns

The table contains four fields:

  • meta_id: The primary key (auto-increment).
  • post_id: The foreign key referencing the post table.
  • meta_key: The string identifier for the custom field.
  • meta_value: The text field storing the data.

#How PHP serialization works in wp_postmeta

When you store complex datasets (like arrays or objects) inside a custom field, WordPress serializes the data using PHP’s maybe_serialize() function:

Array in PHP ---> serialize() ---> String stored in wp_postmeta (meta_value)
                                         |
                               [ Query execution ]
                                         |
Read string ---> maybe_unserialize() ---> Reconstruct PHP Array

A typical serialized PHP array looks like this: a:2:{i:0;s:5:"Apple";i:1;s:6:"Banana";}

In this structure:

  • a:2: Defines an Array containing exactly 2 elements.
  • i:0: Represents an Integer index key of 0.
  • s:5:"Apple": Represents a String of length 5 containing "Apple".
  • i:1: Represents an Integer index key of 1.
  • s:6:"Banana": Represents a String of length 6 containing "Banana".

Other common serialization type markers include:

  • b:1 or b:0: Represents a Boolean value (true or false).
  • d:45.99: Represents a Double (float) decimal value.
  • O:8:"stdClass":1:{...}: Represents an instantiated Object of class length 8 ("stdClass") containing 1 property.
  • N;: Represents a Null value.

In this format, type and string-length markers are explicitly defined (e.g., s:6 indicates a string of exactly 6 characters: "Banana"). Because serialized data is stored as raw text, you cannot query individual array elements inside wp_postmeta efficiently using SQL. Queries that search inside serialized data need slow LIKE string matching, which scans the whole table.

Executing direct database search-and-replace SQL commands (for example, renaming a domain or URL path strings) will corrupt serialized arrays. If you replace http://olddomain.com (20 characters) with https://newdomain.com (21 characters) inside a serialized string without updating the string-length indicator (s:20 to s:21), unserialize() fails and returns false, breaking the field. Always use serialization-aware migration tools (such as WP-CLI’s wp search-replace) to modify metadata safely.

#How to use get_post_meta() in WordPress

The core function for fetching metadata is get_post_meta():

// Single value
$value = get_post_meta( $post_id, 'meta_key', true );

// Array of every value stored under the same key
$values = get_post_meta( $post_id, 'meta_key', false );

// All metadata of the post
$all_meta = get_post_meta( $post_id );

#get_post_meta() parameters

ParameterTypeDescription
$post_idintPost ID
$keystringMeta key name; empty returns all keys
$singlebooltrue = one value, false = array (default)

The third argument is a classic source of bugs. With $single = true, missing meta returns an empty string. With false, you always get an array, even with one element. Code that does if ( $value ) behaves differently on a string and on an array.

Prefix keys with the project name (wppoland_event_city, wppoland_sku). Avoid generic names like price, date or city without a namespace: sooner or later WooCommerce, an SEO plugin or an events plugin claims the same name.

#What get_post_meta() returns for numbers, booleans and missing keys

Everything stored in wp_postmeta comes back as text unless it was serialized. The docblock of get_post_meta() in WordPress core lists the conversions: false is returned as an empty string, true as '1', and integers and floats as strings. Arrays and objects keep their type because WordPress serializes them on save and unserializes them on read.

Edge cases from the same docblock: an invalid post ID (non-numeric, zero or negative) returns false. A valid post ID without the key returns '' with $single = true and an empty array with $single = false. A default registered with register_meta() (available since WordPress 5.5) is never stored, so it comes back in the registered type.

update_post_meta( $post_id, 'wppoland_stock', 0 );

$stock = get_post_meta( $post_id, 'wppoland_stock', true );
var_dump( $stock );        // string(1) "0"
var_dump( 0 === $stock );  // bool(false)

$stock = (int) $stock;     // cast before arithmetic or strict comparison

A strict comparison against an integer never matches a stored number. Cast with (int), (float) or absint() before arithmetic, and compare with '0' === $value when zero is a meaningful value.

#How to check if a custom field exists in WordPress

An empty string from get_post_meta() does not tell you whether the key was saved as empty or never saved. metadata_exists() (since WordPress 3.3) answers that question and reads the same meta cache, so it adds no query when the post meta is already loaded:

if ( metadata_exists( 'post', $post_id, 'wppoland_sku' ) ) {
    $sku = get_post_meta( $post_id, 'wppoland_sku', true );
    // The key exists, even if its value is an empty string.
}

The function runs the get_post_metadata filter first, as its source in meta.php shows, so a plugin that short-circuits meta reads can change the answer. Use it when “empty” and “not set” need different handling, for example when an editor clears a field on purpose and the template should not fall back to a default.

#get_post_meta() versus the_meta()

the_meta() is deprecated since WordPress 6.0.2. Calling it triggers _deprecated_function() with get_post_meta() as the replacement, which shows a notice when WP_DEBUG is on. It prints a <ul class='post-meta'> list of every custom field of the current post in the loop. It escapes keys and values with esc_html() and skips protected keys (those starting with an underscore), but it takes no key argument and gives you no control over the markup or over which keys appear.

// Deprecated: prints every non-protected key of the current post
the_meta();

// Preferred: read one key, decide the HTML, escape for the context
$venue = get_post_meta( get_the_ID(), 'wppoland_venue', true );
if ( $venue ) {
    echo '<p class="venue">' . esc_html( $venue ) . '</p>';
}

Keep the_meta() only in old themes that still call it, or in a debug view on staging. In production, archive templates, single CPT templates and card partials should read explicit keys. That way internal meta (ERP IDs, integration tokens, editorial notes) saved without an underscore prefix never ends up on the public page. The source is in post-template.php.

#How to display a custom field in a theme

#How to show a custom field inside the loop

<?php if ( have_posts() ) : while ( have_posts() ) : the_post(); ?>
    <h2><?php the_title(); ?></h2>

    <?php
    $sku = get_post_meta( get_the_ID(), 'wppoland_sku', true );
    if ( $sku ) :
        ?>
        <p class="sku"><?php echo esc_html( $sku ); ?></p>
    <?php endif; ?>

<?php endwhile; endif; ?>

#How to get a custom field outside the loop

$post_id     = 42;
$author_name = get_post_meta( $post_id, 'custom_author', true );
echo esc_html( $author_name );

On CPT archives, do not rely on the global $post outside the loop: take the ID from get_queried_object_id() or from an argument passed to the partial. It is a frequent cause of “the meta of the previous post shows up” in listings with a shared sidebar.

For reusable partials (an event card, a property sheet), pass the ID as a function argument. Classic templates and block theme patterns often live side by side, and an explicit ID avoids surprises when the same partial runs inside a Query Loop.

#How to escape custom field output

Always escape values upon rendering to prevent Cross-Site Scripting (XSS) injection:

declare(strict_types=1);

namespace WPPoland\Meta;

/**
 * Renders custom event details safely.
 */
function render_event_metadata(): void {
    $post_id = get_the_ID();

    $price    = get_post_meta( $post_id, 'event_price', true );
    $location = get_post_meta( $post_id, 'event_location', true );
    $link     = get_post_meta( $post_id, 'event_ticket_url', true );

    if ( ! empty( $price ) ) {
        printf(
            '<p class="event-price">Price: <strong>%s EUR</strong></p>',
            esc_html( $price )
        );
    }

    if ( ! empty( $location ) ) {
        printf(
            '<p class="event-location">Venue: <strong>%s</strong></p>',
            esc_html( $location )
        );
    }

    if ( ! empty( $link ) ) {
        printf(
            '<p class="event-tickets"><a href="%s" class="btn-primary">Buy Tickets</a></p>',
            esc_url( $link )
        );
    }
}

The context picks the function. Visible text: esc_html. Attribute: esc_attr. URL: esc_url. Trusted editorial HTML: wp_kses_post. Meta filled in by a front-end form (a quote request, a job application, a CV upload) is always untrusted, even on an internal site.

On the save side, use sanitize_text_field, absint, sanitize_email, or your own callback in register_post_meta. Do not assume the meta box “only exists in the admin”: the REST API and bulk edit write meta too.

#How to save custom fields in WordPress

#update_post_meta(), add_post_meta() and delete_post_meta()

update_post_meta( $post_id, 'meta_key', 'meta_value' );
add_post_meta( $post_id, 'meta_key', 'meta_value' );
delete_post_meta( $post_id, 'meta_key' );

update_post_meta creates the row if it does not exist. add_post_meta with the default $unique = false allows several rows under the same key, which should be a deliberate decision and not a side effect of a CSV import.

#How to add a meta box with add_meta_box()

function wppoland_add_sku_metabox() {
    add_meta_box(
        'product_sku_box',
        'Catalogue code',
        'wppoland_render_sku_metabox',
        'post',
        'side'
    );
}
add_action( 'add_meta_boxes', 'wppoland_add_sku_metabox' );

function wppoland_render_sku_metabox( $post ) {
    wp_nonce_field( 'save_sku', 'sku_nonce' );
    $sku = get_post_meta( $post->ID, 'wppoland_sku', true );
    ?>
    <label for="wppoland_sku">SKU:</label>
    <input type="text" id="wppoland_sku" name="wppoland_sku"
           value="<?php echo esc_attr( $sku ); ?>">
    <?php
}

function wppoland_save_sku_metabox( $post_id ) {
    if ( ! isset( $_POST['sku_nonce'] ) ||
        ! wp_verify_nonce( $_POST['sku_nonce'], 'save_sku' ) ) {
        return;
    }

    if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
        return;
    }

    if ( ! current_user_can( 'edit_post', $post_id ) ) {
        return;
    }

    if ( isset( $_POST['wppoland_sku'] ) ) {
        update_post_meta(
            $post_id,
            'wppoland_sku',
            sanitize_text_field( wp_unslash( $_POST['wppoland_sku'] ) )
        );
    }
}
add_action( 'save_post', 'wppoland_save_sku_metabox' );

Nonce, capability check and autosave guard: three items short tutorials leave out and security audits flag first. Also run wp_unslash on $_POST data before sanitizing it.

#How to display ACF fields with get_field()

ACF simplifies the editing UI and the field types (repeater, flexible content, relationship). On many teams the ACF panel is the contract between developer and editors: the code reads get_field(), and the content team never touches PHP.

$sku   = get_field( 'wppoland_sku' );
$image = get_field( 'hero_image' );
the_field( 'product_description' );
$value = get_field( 'field_name', $post_id );

ACF still stores values in postmeta for posts, so meta_query often references the same keys as the native API. In large listings, avoid calling get_field on every card without meta being loaded in bulk first (update_meta_cache or a WP_Query that already primes the meta cache).

If the theme has to keep working with ACF deactivated, keep a fallback:

$sku = function_exists( 'get_field' )
    ? get_field( 'wppoland_sku', $post_id )
    : get_post_meta( $post_id, 'wppoland_sku', true );

For ACF image fields, get_field returns an ID, a URL or an array depending on the field’s return format. Align the return format with what the template expects before passing the value to wp_get_attachment_image or esc_url.

#Is the_field() output escaped in ACF

The get_field() documentation says returned values should be escaped with an appropriate function before output, because ACF does not know where you print them. Escaping is your job whenever you echo get_field().

the_field() behaves differently since ACF 6.2.7. The ACF HTML escaping guide states that from 6.2.5 the ACF shortcode passes its output through wp_kses(), and from 6.2.7 the same applies to the_field() and the_sub_field(). The 6.2.5 security release notes warn that this strips unsafe HTML such as scripts or iframes from textarea and WYSIWYG output. Since ACF 6.2.6, get_field() also accepts a fourth argument, $escape_html, which returns an escaped value and requires $format_value to be true.

// Escaped by ACF (wp_kses) since 6.2.7
the_field( 'event_summary' );

// Not escaped: you choose the function for the context
echo esc_html( get_field( 'event_summary' ) );

// Since 6.2.6: ask the field type for an escaped value
echo get_field( 'event_summary', false, true, true );

If an embed inside a WYSIWYG field disappeared after an ACF update, this is the usual cause. Decide per field whether the HTML is trusted, and print it with wp_kses_post() or a custom allowed-tags list instead of switching escaping off globally.

#How to show the custom fields panel in the block editor

The native custom fields box exists in the block editor too, but it is hidden by default when it has not been used before. The WordPress.org documentation on custom fields describes the path: save the post first, open the Options menu (the three dots in the top toolbar), choose Preferences, open the General tab, turn on Custom fields in the Advanced section, then click Show & Reload Page. The box appears below the content, where Enter new and Add Custom Field create a key and its value.

Two things hide fields from that box. Keys starting with an underscore are protected and never listed, which is why _yoast_wpseo_title or WooCommerce keys stay out of sight. And ACF removes the box by default: the ACF settings reference lists remove_wp_meta_box (added in ACF 5.6.0, default true). To get the native box back while ACF is active:

add_filter( 'acf/settings/remove_wp_meta_box', '__return_false' );

Turning it back on is useful for debugging imports on staging. On production, editors who see raw keys next to ACF fields tend to edit the same value in two places, so keep it off there.

#How to output post meta in a block theme with render_block

In block themes, much of the presentation moves out of the classic single.php into HTML templates and the render_block filter. Meta is still read in PHP; the injection point changes.

add_filter( 'render_block', 'wppoland_inject_sku_after_title', 10, 2 );

function wppoland_inject_sku_after_title( $block_content, $block ) {
    if ( ( $block['blockName'] ?? '' ) !== 'core/post-title' ) {
        return $block_content;
    }

    if ( ! is_singular( 'product' ) ) {
        return $block_content;
    }

    $sku = get_post_meta( get_the_ID(), 'wppoland_sku', true );
    if ( ! $sku ) {
        return $block_content;
    }

    $extra = '<p class="sku">' . esc_html( $sku ) . '</p>';
    return $block_content . $extra;
}

In theme patterns, do not hard-code post IDs from staging. Read meta from the current context (get_the_ID() inside the Query Loop) or pass block attributes.

#How to bind custom fields with the Block Bindings API

Since WordPress 6.5, the Block Bindings API connects core blocks to custom field values without building a custom block. The core/post-meta source reads the value with get_post_meta() at render time.

#Which blocks support Block Bindings

The list of bindable attributes lives in core and has grown with each release, as the release branches of wordpress-develop show:

WordPress versionBindable blocks and attributes
6.5 to 6.8Paragraph (content), Heading (content), Image (id, url, title, alt), Button (url, text, linkTarget, rel)
6.9adds Image caption, Post Date (datetime), Navigation Link and Navigation Submenu (url)
7.1adds List Item (content)

Sources: class-wp-block.php in the 6.5 branch and block-bindings.php in the 7.1 branch.

#register_post_meta() with show_in_rest for block bindings

To expose a custom field to the block editor, register it with register_post_meta() and set show_in_rest to true. This lets the editor fetch, update and display the field through REST requests during editing. Define the data type (string, number, boolean) and an authorization callback so only authorized roles can save changes:

declare(strict_types=1);

namespace WPPoland\Gutenberg\Bindings;

function register_bindable_metadata(): void {
    register_post_meta( 'post', 'custom_author_note', [
        'show_in_rest' => true,
        'single'       => true,
        'type'         => 'string',
        'auth_callback'=> function() {
            return current_user_can( 'edit_posts' );
        }
    ] );
}
add_action( 'init', __NAMESPACE__ . '\\register_bindable_metadata' );

The core/post-meta source returns nothing in four cases: the key is not registered with show_in_rest, the key is protected (it starts with an underscore), the post is not publicly viewable and the visitor cannot read it, or the post is password protected. A key like _custom_author_note therefore never renders through a binding, even when it is registered. Use an unprefixed key for anything you want to bind.

#How to bind a custom field to a paragraph block

Once registered, bind the meta field to a Paragraph block in a template or pattern:

<!-- wp:paragraph {
  "metadata": {
    "bindings": {
      "content": {
        "source": "core/post-meta",
        "args": {
          "key": "custom_author_note"
        }
      }
    }
  }
} -->
<p>Default fallback text placeholder displayed if post meta is empty.</p>
<!-- /wp:paragraph -->

On the front end, core replaces the paragraph content with the current meta value. The binding does not replace server-side escaping for HTML you write by hand in render_block or in PHP templates.

#How to register a custom block bindings source

When the value needs processing (a formatted price, a date in a site-specific format), register your own source in PHP with register_block_bindings_source() on the init hook. The callback receives the binding arguments, the block instance and the attribute name, as the docblock in block-bindings.php describes:

add_action( 'init', function () {
    register_block_bindings_source( 'wppoland/formatted-price', [
        'label'              => __( 'Formatted price', 'wppoland' ),
        'uses_context'       => [ 'postId' ],
        'get_value_callback' => function ( array $source_args, $block_instance ) {
            $post_id = $block_instance->context['postId'] ?? get_the_ID();
            $raw     = get_post_meta( $post_id, $source_args['key'] ?? '', true );

            return '' === $raw ? null : number_format_i18n( (float) $raw, 2 );
        },
    ] );
} );

The block markup then uses "source": "wppoland/formatted-price" with the meta key in args. Source names need a namespace prefix, such as wppoland/, and may only contain lowercase letters, digits, dashes and slashes.

#How to speed up meta_query with a SQL index

When querying posts by metadata using meta_query in WP_Query, WordPress generates SQL JOIN statements linking wp_posts and wp_postmeta. On large wp_postmeta tables, these queries can slow database responses down.

#Why meta_query adds JOINs and filesorts

Every key-value constraint inside a meta_query block results in an additional INNER JOIN or LEFT JOIN on the wp_postmeta table in the generated SQL statement. When the MySQL or MariaDB optimizer processes this query:

  1. It must resolve the joins by matching post_id foreign keys.
  2. If the query orders results by a metadata field (using orderby => meta_value), the database engine is forced to perform a filesort operation.
  3. Because the meta_value column is defined as longtext, the database engine cannot use standard indexing for ordering unless a prefix index is configured.
  4. The sort may need temporary tables on disk, which adds I/O and latency on busy sites.

#How to add a composite index to wp_postmeta

Core indexes meta_key but not meta_value. To optimize lookups that filter on both, add a composite index on meta_key and the beginning of meta_value. Test it on a copy of the database first and compare EXPLAIN output before and after:

CREATE INDEX idx_meta_key_value ON wp_postmeta (meta_key, meta_value(191));

#Meta cache and update_meta_cache()

In a standard WP_Query, WordPress calls update_meta_cache() and loads the meta of the found posts in one batch. Repeated get_post_meta() calls on the same ID in the same request do not run extra SELECTs, because the values sit in the object cache (persistent with Redis or Memcached, or the per-request in-memory cache otherwise).

Anti-pattern: an archive of 48 cards that runs a separate WP_Query per card just to read one meta value. Prefer one query with meta_query, or two steps (collect IDs, then get_posts with the meta cache already primed). Do not order by meta_value on large tables without an index; sometimes a taxonomy or a custom table is the right denormalization.

Page cache plugins serve static HTML, so meta is only re-evaluated when the page is regenerated. If meta changes through cron or an ERP webhook, purge that URL or use deliberate fragment caching.

#How to create a custom metadata table in WordPress

If your application queries posts by several metadata fields at once (sorting products by price, location and rating), the EAV model becomes a bottleneck. In this case, create a custom flat SQL table where each field has its own indexed column.

#Custom metadata table helper class in PHP

Put this schema migration and write helper in a plugin (the activation hook below only fires from a plugin’s main file) to handle custom table inserts, deletions, and query wrapping:

declare(strict_types=1);

namespace WPPoland\Database\CustomTable;

/**
 * Creates a custom table to store event metadata.
 */
function create_custom_events_table(): void {
    global $wpdb;
    $table_name = $wpdb->prefix . 'event_metadata_flat';
    $charset_collate = $wpdb->get_charset_collate();

    $sql = "CREATE TABLE $table_name (
        post_id bigint(20) NOT NULL,
        event_price decimal(10,2) NOT NULL DEFAULT 0.00,
        event_location varchar(255) NOT NULL,
        event_date datetime DEFAULT NULL,
        PRIMARY KEY  (post_id),
        KEY event_price (event_price),
        KEY event_date (event_date)
    ) $charset_collate;";

    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    dbDelta( $sql );
}
register_activation_hook( __FILE__, __NAMESPACE__ . '\\create_custom_events_table' );

/**
 * Syncs metadata changes to the custom flat table.
 */
function sync_event_metadata_to_custom_table( int $post_id ): void {
    if ( 'event' !== get_post_type( $post_id ) ) {
        return;
    }

    global $wpdb;
    $table_name = $wpdb->prefix . 'event_metadata_flat';

    $price    = (float) get_post_meta( $post_id, 'event_price', true );
    $location = sanitize_text_field( get_post_meta( $post_id, 'event_location', true ) );
    $date     = sanitize_text_field( get_post_meta( $post_id, 'event_date', true ) );

    $wpdb->replace(
        $table_name,
        [
            'post_id'        => $post_id,
            'event_price'    => $price,
            'event_location' => $location,
            'event_date'     => ! empty( $date ) ? $date : null
        ],
        [ '%d', '%f', '%s', '%s' ]
    );
}
add_action( 'save_post_event', __NAMESPACE__ . '\\sync_event_metadata_to_custom_table', 99 );

/**
 * Automatically drops custom metadata rows when a post is permanently deleted.
 */
function delete_event_metadata_on_purge( int $post_id ): void {
    global $wpdb;
    $table_name = $wpdb->prefix . 'event_metadata_flat';
    $wpdb->delete( $table_name, [ 'post_id' => $post_id ], [ '%d' ] );
}
add_action( 'deleted_post', __NAMESPACE__ . '\\delete_event_metadata_on_purge' );

/**
 * Retrieves sorted event lists directly from the flat table with transient caching.
 */
function get_flat_events_sorted_by_price( int $limit = 10 ): array {
    global $wpdb;
    $table_name = $wpdb->prefix . 'event_metadata_flat';

    $cache_key = 'wppoland_flat_events_price_' . $limit;
    $cached = get_transient( $cache_key );
    if ( false !== $cached ) {
        return $cached;
    }

    $results = $wpdb->get_results( $wpdb->prepare(
        "SELECT post_id, event_price, event_location
         FROM {$table_name}
         ORDER BY event_price ASC
         LIMIT %d",
        $limit
    ), ARRAY_A );

    set_transient( $cache_key, $results, 4 * HOUR_IN_SECONDS );
    return $results;
}

With this structure, sorted event lists come from a simple indexed query on the flat table instead of joins on wp_postmeta.

#How to encrypt ACF field values before saving

If your custom fields store sensitive data (like API tokens), encrypt values before saving them to the database. Here is an implementation using the ACF acf/update_value and acf/load_value filters. It refuses to encrypt when no secret key is defined, because a hard-coded fallback key in the theme would protect nothing:

declare(strict_types=1);

namespace WPPoland\ACF\Security;

/**
 * Returns the encryption key, or an empty string when none is configured.
 */
function get_encryption_key(): string {
    return defined( 'SECURE_AUTH_KEY' ) ? (string) SECURE_AUTH_KEY : '';
}

/**
 * Encrypts ACF custom field values.
 */
function encrypt_acf_field_value( $value, $post_id, $field ) {
    $key = get_encryption_key();
    if ( empty( $value ) || '' === $key ) {
        return $value;
    }

    $method = 'aes-256-cbc';
    $iv = openssl_random_pseudo_bytes( openssl_cipher_iv_length( $method ) );

    $encrypted = openssl_encrypt( (string) $value, $method, $key, 0, $iv );

    // Return payload containing base64 data and the IV string
    return base64_encode( $iv . '::' . $encrypted );
}
add_filter( 'acf/update_value/name=sensitive_api_token', __NAMESPACE__ . '\\encrypt_acf_field_value', 10, 3 );

/**
 * Decrypts ACF custom field values.
 */
function decrypt_acf_field_value( $value, $post_id, $field ) {
    $key = get_encryption_key();
    if ( empty( $value ) || '' === $key ) {
        return $value;
    }

    $payload = base64_decode( $value, true );
    if ( false === $payload || strpos( $payload, '::' ) === false ) {
        return $value; // Return raw value if not encrypted
    }

    list( $iv, $encrypted ) = explode( '::', $payload, 2 );

    return openssl_decrypt( $encrypted, 'aes-256-cbc', $key, 0, $iv );
}
add_filter( 'acf/load_value/name=sensitive_api_token', __NAMESPACE__ . '\\decrypt_acf_field_value', 10, 3 );

Rotating SECURE_AUTH_KEY makes every stored value unreadable, so a key rotation needs a re-encryption script.

#Exposing custom fields in the REST API

For decoupled headless frontend stacks, expose custom fields as elements of REST API post objects. register_rest_field() adds a computed field with its own callbacks:

declare(strict_types=1);

namespace WPPoland\API\Meta;

function register_rest_custom_fields(): void {
    register_rest_field( 'post', 'author_note', [
        'get_callback' => function( array $post_object ) {
            return get_post_meta( $post_object['id'], 'custom_author_note', true );
        },
        'update_callback' => function( $value, \WP_Post $post_object ) {
            return update_post_meta( $post_object->ID, 'custom_author_note', sanitize_text_field( $value ) );
        },
        'schema' => [
            'type'        => 'string',
            'description' => 'Author note metadata',
            'context'     => [ 'view', 'edit' ]
        ]
    ] );
}
add_action( 'rest_api_init', __NAMESPACE__ . '\\register_rest_custom_fields' );

#Why custom fields do not show in the REST API for a custom post type

register_post_meta() with show_in_rest puts the value under the meta key of the post object. For a custom post type there is one more condition, written in the register_meta() docblock: the post type must declare support for custom fields, or registered meta stays out of the REST response.

register_post_type( 'event', [
    'public'       => true,
    'show_in_rest' => true,
    'supports'     => [ 'title', 'editor', 'custom-fields' ],
] );

register_post_meta( 'event', 'wppoland_venue', [
    'show_in_rest'      => true,
    'single'            => true,
    'type'              => 'string',
    'sanitize_callback' => 'sanitize_text_field',
    'auth_callback'     => function () {
        return current_user_can( 'edit_posts' );
    },
] );

The same docblock lists two newer arguments. revisions_enabled (WordPress 6.4) stores meta with post revisions and only works for the post object type. label (WordPress 6.7) gives the key a human-readable name. Without auth_callback, you may open writes to a wider group than intended. The type must match the real data: number versus a string in JSON is a source of silent bugs in the editor sidebar. For composite fields (objects, lists), configure show_in_rest with a schema. Sensitive fields (gateway tokens, internal billing IDs) should keep show_in_rest off; the theme can still read them in PHP at render time without publishing them under /wp-json/.

#Common mistakes with custom fields in WordPress

  1. Printing meta without esc_html, esc_attr or esc_url.
  2. Saving from $_POST without a nonce and without current_user_can.
  3. The same meta key used by two plugins (or by WooCommerce and the theme).
  4. get_field in an archive loop without a primed meta cache.
  5. Assuming $single = true always returns a string (serialized arrays from old imports).
  6. A block built without register_post_meta: “works in PHP, fails in the editor”.
  7. Using the_meta() in production and exposing internal keys on the public page.
  8. Binding a protected _key with Block Bindings and wondering why nothing renders.

#How to name meta keys and migrate from ACF

When you inherit an old theme, inventory the keys: SELECT meta_key, COUNT(*) FROM wp_postmeta GROUP BY meta_key ORDER BY COUNT(*) DESC LIMIT 50. You see at once which prefixes belong to the project and which were left by plugins removed long ago. A project prefix (wppoland_, client_) protects against collisions with ACF or WooCommerce.

When migrating from ACF to native register_post_meta, do not copy field_xxxxx references blindly. Keep a readable business key and, if needed, a one-off script that maps old IDs to new ones. Staging first: compare the number of posts with non-empty meta before and after.

In multisite, get_post_meta always works on the current blog. Wrapping meta reads in switch_to_blog without restore_current_blog in a finally block is a classic way to mix data from another site of the network.

#Why get_post_meta returns an array instead of a string

get_post_meta( $id, $key, true ) can return an array when the database holds a serialized PHP array from an old migration. Code that assumes a string raises notices or prints the word “Array”. Check the type before esc_html, or normalize the value on save.

Avoid manual serialize() and unserialize() on user data. WordPress serializes composite meta values itself; you pass the array to update_post_meta. For integrations with external systems, map fields to scalar keys instead of one blob, which keeps meta_query usable later.

Test meta saving as an editor, not only as an administrator. A meta box or an ACF group should carry a one-line help text for editors: where the value comes from, whether it is required, and what the front end does when it is empty. In the template, handle the empty state on purpose (hide the section or show a fallback) instead of rendering empty tags.

#Custom fields checklist before deployment

  1. Every output uses esc_html, esc_attr, esc_url or wp_kses_post for its context.
  2. Save handlers check a nonce and current_user_can.
  3. Meta used in REST has show_in_rest and auth_callback, and custom post types support custom-fields.
  4. No ORDER BY meta_value on large archives without a reason and an index.
  5. Old plugin keys are documented, or removed by a cleanup script tested on staging.
  6. No production template calls the_meta() for public content.
  7. Bound meta keys have no underscore prefix.

#How to optimize WordPress custom fields in 90 days

Follow this timeline to optimize, extend, and secure your site’s custom fields:

  • Days 1-30: Escape all metadata output in theme templates, register fields for the REST API, and test a composite index on a staging copy of wp_postmeta.
  • Days 31-60: Connect metadata fields to core blocks with the Block Bindings API, and encrypt sensitive keys with ACF filters.
  • Days 61-90: Move high-volume query endpoints to custom flat tables, load-test query speed, and configure caching.

Need help building structured custom data architectures? My WordPress development team can audit your database, build flat custom tables, and secure sensitive field payloads. Contact us to discuss your project requirements.

Next step

Turn the article into an actual implementation

This block strengthens internal linking and gives readers the most relevant next move instead of leaving them at a dead end.

Want this implemented on your site?

If you want to convert the article into a working site improvement, redesign, or build plan, I can define the scope and implement it.

Explore other WordPress services and knowledge base

Frequently asked questions

Practical answers to apply the topic in real execution.

How does get_post_meta handle serialized arrays?#
It automatically checks for serialized data and runs maybe_unserialize() before returning the value, returning a clean PHP array.
Why are complex meta_query filters slow on large databases?#
Because every sub-query in a meta_query generates an SQL JOIN and a wildcard scan. Without indexes, this forces full-table scans.
Can I bind custom fields directly to core blocks in Gutenberg?#
Yes. WordPress 6.5+ supports the Block Bindings API, allowing you to bind post meta values directly to image, paragraph, or heading blocks.
Is it safe to output custom field values directly?#
No. Always run output escaping (e.g. esc_html, esc_url, or wp_kses_post) to mitigate Cross-Site Scripting (XSS) risks.
What is the difference between update_post_meta and add_post_meta?#
The add_post_meta function registers a new metadata row and can create duplicate rows for the same key. The update_post_meta function updates the row if it exists, and falls back to registering a new one if not.

Related Articles