By Matija Žiberna. Tested against WordPress 6.5+ and MySQL 8.0. Last updated August 2026.
WordPress stores nearly every piece of content, including blog posts, pages, uploaded media, and every custom post type, as a row in one table called wp_posts, using a post_type column to tell each row apart. Custom fields, including anything built with Advanced Custom Fields, live in a second table called wp_postmeta, where each field is its own row in a key-value pair. This design is called Entity-Attribute-Value, or EAV. Categories, tags, and any other taxonomy get split across three more tables that connect terms to posts through a join table. Together, these six tables cover more than 95% of what a typical WordPress site stores, even on installs running 50 to 200+ tables once plugins are counted.
This guide traces a single custom post type through all six tables, shows the SQL query WordPress runs to reassemble it into a page, and closes with how this model compares to a typed CMS like Payload.
I mapped this out while scoping a WordPress-to-Payload migration for a client site running a dozen Advanced Custom Fields groups across five custom post types. The admin screens showed purpose-built forms for recipes, products, and events. Underneath those forms, the database held everything in one large table for content, one large table for every custom field value, and a web of taxonomy joins connecting them. Once that pattern was clear, migrating each post type to a typed Payload collection came down to mapping meta keys directly to fields.
Most explanations of the WordPress database describe wp_posts and wp_postmeta as separate topics and stop there. Few trace one real object through every table it touches, which is where the pattern actually becomes usable. This guide does that with a Recipe custom post type, including the serialized array and the taxonomy joins that shorter explanations tend to skip.
The 6 Core Tables at a Glance
Diagram
Concept 1: Single-Table Polymorphism (wp_posts)
WordPress stores nearly everything, including blog posts, pages, uploaded media, and every custom post type, as a row in a single table called wp_posts. A column named post_type acts as a discriminator that tells WordPress what kind of object each row represents.
ID
post_title
post_name (slug)
post_type
What it represents
4821
Best Practices for Server-Side Rendering
best-practices-server-side-rendering
post
A standard blog article
512
About Us
about-us
page
A static landing page
4815
hero-banner-desktop.jpg
hero-banner-desktop-jpg
attachment
An uploaded image in the Media Library
3390
Classic Margherita Pizza
classic-margherita-pizza
recipes
A Custom Post Type (CPT)
3388
Ceramic Coffee Mug
ceramic-coffee-mug
products
A Custom Post Type (CPT)
3512
Autumn Trail Run 10K
autumn-trail-run-10k
event
A Custom Post Type (CPT)
2201
Customer Spotlight: Dana R.
customer-spotlight-dana-r
stories
A Custom Post Type (CPT)
1150
Shop Now
shop-now
nav_menu_item
A navigation menu item link
Uploaded media and navigation menu links use the same table as blog posts and products. An attachment row and a nav_menu_item row both live in wp_posts, right next to the post and page rows.
Concept 2: The EAV Custom Field Pattern (wp_postmeta)
The wp_posts table has a fixed set of columns: post_title, post_content, post_excerpt, post_date, and a handful more. A Recipe post type needs fields like servings and preparation_time that have no matching column. A Product post type needs a SKU. WordPress solves this with the EAV pattern in wp_postmeta, where each custom field value gets its own row instead of its own column.
meta_id
post_id
meta_key
meta_value
5001
3390
servings
4 servings
5002
3390
preparation_time
25 minutes
5003
3390
recipe_author
Jordan Blake
5004
3390
_thumbnail_id
3392(Points to another row in wp_posts)
5005
3390
related-products
a:1:{i:0;s:4:"3395";}(Serialized PHP array)
How ACF (Advanced Custom Fields) Uses This Table
ACF writes two rows to wp_postmeta for every field you create. One row holds the value a visitor sees, for example servings set to 4 servings. A second row, prefixed with an underscore such as _servings, stores the ACF field key, something like field_68cbbb213d5f9, which points back to the field's definition, itself stored as a row in wp_posts.
Concept 3: The 3-Table Taxonomy System
Categories, tags, and any custom taxonomy skip wp_posts and wp_postmeta entirely. WordPress normalizes this data across three more tables.
wp_terms stores the human-readable name and slug, such as Italian and italian. wp_term_taxonomy declares what kind of classification a term belongs to, for example cuisine, dietary, or category, and whether it has a parent term. wp_term_relationships is the many-to-many junction table that connects a specific post_id (stored as object_id) to a term_taxonomy_id.
End-to-End Example: Tracing a Single Recipe
Here is how a single published recipe, Classic Margherita Pizza, is stored across the entire database.
Diagram
1. In wp_posts (The Main Record)
ID: 3390
post_title: "Classic Margherita Pizza"
post_name: "classic-margherita-pizza"
post_content: "This Classic Margherita Pizza uses just four ingredients and a hot oven..."
post_status: "publish"
post_type: "recipes"
2. In wp_postmeta (The ACF Fields and Relationships)
object_id: 3390 ↔ term_taxonomy_id: 640 → Taxonomy recipe_by_product: Pizza Stone
How WordPress Reassembles This Data (The SQL Query)
When a visitor loads /recipes/classic-margherita-pizza, WordPress runs an internal SQL join to reassemble the entity from all six tables:
sql
SELECT
p.ID,
p.post_title AS recipe_title,
p.post_content AS intro,
pm_servings.meta_value AS servings,
pm_time.meta_value AS prep_time,
pm_author.meta_value AS author_name,
GROUP_CONCAT(DISTINCT t.name SEPARATOR ', ') AS dietary_tags
FROM wp_posts p
-- Join Custom FieldsLEFTJOIN wp_postmeta pm_servings ON p.ID = pm_servings.post_id AND pm_servings.meta_key ='servings'LEFTJOIN wp_postmeta pm_time ON p.ID = pm_time.post_id AND pm_time.meta_key ='preparation_time'LEFTJOIN wp_postmeta pm_author ON p.ID = pm_author.post_id AND pm_author.meta_key ='recipe_author'-- Join TaxonomiesLEFTJOIN wp_term_relationships tr ON p.ID = tr.object_id
LEFTJOIN wp_term_taxonomy tt ON tr.term_taxonomy_id = tt.term_taxonomy_id AND tt.taxonomy ='dietary'LEFTJOIN wp_terms t ON tt.term_id = t.term_id
WHERE p.post_name ='classic-margherita-pizza'AND p.post_type ='recipes'GROUPBY p.ID;
Each LEFT JOIN pulls in one more table from the six-table set. The pm_servings, pm_time, and pm_author aliases each hit wp_postmeta separately because every custom field lives on its own row, so a single post with ten custom fields needs ten joins (or ten separate queries) to pull them all back out as columns.
Why Plugins Create Bespoke Tables
Searching across millions of unindexed key-value pairs in wp_postmeta gets slow at high volume. Plugins built for high write and query volume tend to create their own SQL tables for that reason, rather than writing into wp_postmeta.
Gravity Forms stores submissions in wp_gf_entry and wp_gf_entry_meta rather than wp_posts, because a site with 140,000+ form submissions would bloat the main content table and slow down every page query on the site. WPML coordinates translated post pairs through wp_icl_translations, linking an English post and its French counterpart under a shared translation group ID. All in One SEO uses wp_aioseo_posts and wp_aioseo_redirects for crawler analysis and fast 301 redirect lookups, work that would be expensive to run against wp_postmeta directly.
WordPress vs a Typed CMS: Same Data, Different Storage Model
Why does WordPress store custom fields as rows instead of columns?
Adding a column to wp_posts for every possible custom field across every plugin and theme would require constant schema migrations and would leave most rows with mostly empty columns. Storing each field as a meta_key and meta_value row lets any post type add any field without touching the table structure.
What's the difference between wp_postmeta and wp_options?wp_postmeta stores key-value data tied to a specific post through post_id. wp_options stores site-wide settings that aren't tied to any single post, such as the site title, active theme, and plugin configuration.
Does every custom field in WordPress go through the EAV pattern?
Fields added through ACF, native custom fields, and most third-party field plugins write to wp_postmeta. Some page builders and form plugins store their data in dedicated tables instead, for the same performance reasons covered above.
Why do some plugins build their own tables instead of using wp_postmeta?wp_postmeta is a single shared table across the entire site. Plugins expecting high write volume or complex relational queries, like form submissions or redirect maps, get better performance from a dedicated table with proper indexes than from filtering millions of rows in a shared EAV table.
Can wp_postmeta be queried efficiently at scale?
It can be indexed on post_id and meta_key, which covers most lookups for a single post's fields. Filtering or sorting across meta values for many posts at once, such as "find every recipe under 30 minutes," gets expensive because meta_value is stored as text and isn't typed or indexed for range queries.
Wrapping Up
WordPress represents an enormous range of content types, from blog posts to recipes to navigation links, using just six core tables. wp_posts holds every content object with a post_type discriminator, wp_postmeta holds every custom field as a key-value row, and the three taxonomy tables normalize categories and tags into a shared junction structure. Tracing one real post through all six tables, as this guide did with a Recipe CPT, is what makes the pattern concrete enough to use.
Let me know in the comments if you have questions, and subscribe for more practical development guides.