How to Read wp_postmeta to Reconstruct a Site’s Custom Field Strategy

Reading wp_postmeta means going straight to the database table that gets kicked around more than any other in WordPress. You’re mapping every custom field, hidden config flag, and leftover plugin artifact that’s latched onto your content. The territory includes post metadata normalization, auditing serialized junk, and the accidental meta_key taxonomy that appears the moment you treat metadata as a real data model. For small-to-mid publishing teams running lean engineering stacks, this matters because a messy wp_postmeta quietly strangles query performance, editorial consistency, and clean migrations. You can’t build a durable custom field strategy until you see exactly what’s already sitting in the table, and the only way to see it is to read it raw.

Close-up of database schema diagrams on a screen

Why wp_postmeta Becomes a Junkyard So Fast

WordPress stores post metadata in a dead-simple key-value table: meta_id, post_id, meta_key, meta_value. That simplicity is the trap. Every plugin, theme, and block editor dumps configuration, layout data, SEO scores, and transient flags into the same table. There’s no enforced schema, no type safety, and no garbage collection unless you build it yourself. A publishing team with 5,000 posts and 15 active plugins can easily rack up 200,000+ rows, many of them orphaned or serialized into unreadable blobs. The first failure mode is invisible: a WP_Query with a meta_query that scans hundreds of thousands of rows because the meta_key isn’t indexed. The second hits during migrations, when serialized PHP objects break because string length counts no longer match after a search-and-replace. The third is editorial: writers see inconsistent custom field boxes, or fields that appear on some posts but not others, because the underlying meta_key presence depends on a plugin’s internal state. None of these problems show up on a pretty dashboard. You have to go to the database.

Start with a Direct SQL Inventory

Before you touch a line of PHP, run a diagnostic query. Connect to your database with something like TablePlus, Adminer, or the MySQL command line. The goal is a frequency distribution of every meta_key in the table, grouped by the number of posts using it. This query gives you the raw landscape:

SELECT meta_key, COUNT(DISTINCT post_id) AS post_count, COUNT(*) AS row_count
FROM wp_postmeta
GROUP BY meta_key
ORDER BY row_count DESC;

Run it. Export the result to a spreadsheet. You’ll see keys you recognize—_thumbnail_id, _wp_page_template—and dozens you don’t. Keys prefixed with an underscore are nominally hidden from the custom field UI, but they’re fully visible in the database. Keys without the underscore were likely created by the Custom Fields metabox, Advanced Custom Fields (ACF), or a plugin that ignored the underscore convention. The gap between row_count and post_count tells you if a key stores multiple values per post, which is common for repeaters, galleries, and some SEO plugins. A key with 10,000 rows but only 500 distinct post IDs means an average of 20 rows per post. That’s a performance signal you need to understand before you build anything on top of it.

Identify Orphaned Metadata from Deleted Plugins

Plugins leave ghosts. You deactivated “WP Super Gallery” two years ago, but every post still has _wpsg_slideshow_data rows. These orphans bloat the table, slow down backups, and occasionally collide with new plugins that pick the same key name. Cross-reference your key inventory against your active plugin list. For each suspicious key, search your entire codebase—wp-content/plugins and wp-content/themes—using grep or your IDE’s search. If the key doesn’t appear in any active code, it’s an orphan. Don’t delete it yet. Export a sample of the values first, because some “orphans” are actually used by your theme’s functions.php or a must-use plugin you forgot about. Once confirmed, delete with:

DELETE FROM wp_postmeta WHERE meta_key = '_wpsg_slideshow_data';

Run this during low-traffic hours and after a backup. The table uses a non-locking storage engine in most setups, but large deletes can still cause replication lag on clustered configurations.

Decode Serialized Data Without Losing Your Mind

WordPress stores arrays and objects as PHP serialized strings. A typical meta_value looks like a:2:{s:5:"width";i:800;s:6:"height";i:600;}. You can’t query inside these strings with SQL. If you need to find all posts where a serialized field contains a specific value, you have two options: pull the data into a script and deserialize it, or use a targeted LIKE query as a rough filter. The LIKE approach is fragile—string length prefixes in serialized data mean a simple search-and-replace will corrupt the data. For auditing, write a small PHP script that runs via WP-CLI:

$results = $wpdb->get_results( "SELECT post_id, meta_value FROM wp_postmeta WHERE meta_key = 'my_repeater_field'" );
foreach ( $results as $row ) {
    $data = maybe_unserialize( $row->meta_value );
    // Inspect $data structure
}

This script reveals the actual shape of the data: is it a simple array, a nested associative array, or a serialized object from a class that no longer exists? The last case is a migration time bomb. If the class definition is missing, maybe_unserialize returns an incomplete __PHP_Incomplete_Class object, and any code that tries to access its properties will fatal error. The fix is to either restore the class (reinstall the plugin temporarily) or convert the data to a plain array before uninstalling the plugin permanently.

Code editor showing PHP serialized data structure

Map the Implicit Custom Field Strategy

Every active meta_key that isn’t an orphan represents a design decision, whether intentional or accidental. Your job is to make it intentional. Group the keys by functional domain: content structure, SEO, layout, editorial workflow, analytics, third-party integrations. For a publishing team, the content structure domain is the most critical. It includes fields like _wp_page_template, ACF field groups that define article components (byline overrides, subtitle, featured video URL), and block editor metadata stored under keys like _wp_page_template or block attributes serialized in the post content itself. The layout domain includes page builder data—Elementor’s _elementor_data, Beaver Builder’s _fl_builder_data—which are massive serialized JSON or PHP arrays. The editorial workflow domain includes keys from plugins like Edit Flow or PublishPress that store editorial comments, assignment data, and custom statuses. The SEO domain is dominated by Yoast or Rank Math keys: _yoast_wpseo_title, _yoast_wpseo_metadesc, _yoast_wpseo_focuskw, and their Rank Math equivalents. Each domain has different access patterns, different indexing needs, and different cleanup rules.

Document the Access Pattern for Each Key

For every key in your content structure domain, answer three questions: Which queries use this key in a meta_query? Is the key used for sorting? Is the key used for display on the front end? A key that is only used for display—like a custom subtitle that is pulled via get_post_meta() on single templates—doesn’t need an index. A key that is used to filter an archive query—like a custom “review score” that powers a “Top Rated” page—absolutely needs an index. Create the index manually if the plugin that owns the key didn’t:

ALTER TABLE wp_postmeta ADD INDEX idx_review_score (meta_key(191), meta_value(191));

The (191) prefix length is required because meta_value is a LONGTEXT column and MySQL refuses to index the full column without a prefix limit. Choose a prefix length that captures the meaningful portion of your values. For numeric scores stored as strings, 10 characters is plenty. For URL fields, 191 is the safe maximum for InnoDB with utf8mb4.

Detect and Resolve Key Collisions

Two plugins using the same meta_key name is rare but catastrophic when it happens. I’ve seen a custom theme store author bio data under _author_bio while a since-abandoned “Author Box” plugin also wrote to _author_bio with a completely different data structure. The result was that get_post_meta() returned an unpredictable mix of strings and serialized arrays, breaking the author box display intermittently. Detect collisions by checking if a single post_id has multiple rows for the same meta_key with structurally different values. This query finds posts with duplicate keys:

SELECT post_id, meta_key, COUNT(*) AS cnt
FROM wp_postmeta
WHERE meta_key = '_author_bio'
GROUP BY post_id, meta_key
HAVING cnt > 1;

If you find duplicates, export all values for those post IDs and manually determine which plugin owns the correct data. Delete the incorrect rows by meta_id, not by value, to avoid accidentally removing the wrong copy.

Rebuild the Strategy as a Single Configuration File

Once you’ve audited, cleaned, and indexed the table, encode the intentional custom field strategy in a PHP configuration file that lives in your theme or a must-use plugin. This file should register all custom fields explicitly, even if a plugin like ACF normally handles registration. The reason: plugins get deactivated, configurations get lost in database exports, and a new developer joining the team needs a single source of truth. A minimal configuration looks like:

return [
    'post_types' => [
        'post' => [
            'fields' => [
                'subtitle' => [
                    'meta_key' => '_theme_subtitle',
                    'type' => 'string',
                    'index' => false,
                    'display_only' => true,
                ],
                'review_score' => [
                    'meta_key' => '_theme_review_score',
                    'type' => 'float',
                    'index' => true,
                    'queryable' => true,
                ],
            ],
        ],
    ],
];

This isn’t a replacement for ACF’s UI. It’s a contract. The ACF field group definition should match this contract. If they diverge, the contract wins, and you update ACF to match. This prevents the “works in the editor but breaks the query” class of bugs that happen when someone changes a field type in ACF without updating the custom WP_Query that filters on it.

PHP configuration file open in a code editor with syntax highlighting

Handle the Block Editor’s Metadata Footprint

Gutenberg stores block attributes in the post content as HTML comments with JSON payloads, but it also writes metadata. Reusable blocks live in the wp_posts table as a custom post type wp_block, but their usage is tracked via wp_postmeta keys like _wp_reusable_block_instance. Template assignments for FSE themes are stored in _wp_page_template and _wp_template. If your team uses the block editor with custom blocks, audit the meta_key namespace those blocks use. Custom blocks register metadata via register_meta() or register_block_type() with an attributes schema that specifies a meta source. Each attribute with a meta source creates a meta_key entry. The block’s render_callback then reads that key. If the block is removed from the site but posts still contain it, the metadata rows persist. Your inventory query will catch them. The cleanup process is the same as for plugin orphans, but you must also strip the block’s HTML comment from post_content to prevent rendering errors. A WP-CLI command like wp post content clean can help, but test it on a staging copy first—it can mangle nested block structures.

Prevent Future Rot with a Metadata Governance Routine

Add a monthly WP-CLI command to your team’s maintenance checklist that re-runs the key inventory query and diffs it against the previous month. Store the baseline in version control. Any new key that appears without a corresponding entry in the configuration contract is a red flag. It means someone installed a plugin, activated a feature, or added a custom field without documenting it. The fix isn’t to delete the key; it’s to update the contract and decide whether the key belongs in the strategy. This routine takes five minutes and prevents the slow accumulation of technical debt that turns a clean wp_postmeta into a junkyard within six months.

If your metadata governance reveals deeper structural problems—like a site that returns “Nothing Found” on archive pages despite having published posts—the root cause often lives in query alterations driven by metadata. I covered the diagnostic sequence for that exact failure in What to Fix First When a New WordPress Site Says Nothing Found. The connection is direct: a pre_get_posts filter that adds a meta_query for a key that doesn’t exist on most posts will silently produce empty archives. Your wp_postmeta audit is the tool that catches it.

FAQ

How do I know if a meta_key is safe to delete?

Confirm the key isn’t referenced in any active theme or plugin code by searching the entire wp-content directory. Then export a sample of the values to verify they aren’t used by an external service that reads the database directly. If both checks pass, delete the rows during a maintenance window after taking a full database backup. Start with a SELECT to count affected rows, then run the DELETE in a transaction if your storage engine supports it.

Why are some custom fields visible in the database but not in the WordPress editor?

WordPress hides meta keys prefixed with an underscore from the default Custom Fields metabox. Plugins like Advanced Custom Fields can also hide fields they manage. The data is still in wp_postmeta and fully accessible via get_post_meta(). If you need to expose a hidden field in the editor, either remove the underscore prefix (which may break the owning plugin) or use a plugin that provides a UI for managing all metadata regardless of prefix.

What is the performance impact of storing large serialized arrays in wp_postmeta?

MySQL must read the entire row to access any part of it, even if you only need one value from a serialized array. A 50KB serialized array stored in meta_value forces MySQL to pull 50KB from disk every time the row is accessed, even for queries that don’t use the meta_value column. This inflates I/O, reduces cache efficiency, and slows down any query that joins against wp_postmeta. If you frequently query individual values inside a serialized array, extract them into separate meta keys with proper indexing.

Can I convert serialized data to JSON in wp_postmeta without breaking things?

Only if you update every piece of code that reads and writes that meta key. WordPress core functions like get_post_meta() and update_post_meta() are format-agnostic—they store and return whatever you give them. The risk is that plugins and themes expect PHP-serialized data and will fail when handed JSON. Before converting, audit all code that touches the key. If you control all of it, JSON is generally safer for portability and avoids the class-instantiation risks of PHP serialization.