BuildWithMatija
  1. Home
  2. Blog
  3. Tools
  4. WordPress Database Structure: wp_posts & wp_postmeta

WordPress Database Structure: wp_posts & wp_postmeta

Trace one post through wp_posts, wp_postmeta and taxonomy tables; learn EAV, ACF patterns, SQL joins, and migration tips

13th August 2026·Updated on:18th August 2026··
Tools
WordPress Database Structure: wp_posts & wp_postmeta

Comparing Headless CMS Options?

Answer 10 simple questions and get an independent recommendation matched to your project, budget, and team structure.

Try the CMS PickerGet a Second Opinion

📚 Get Practical Development Guides

Join developers getting comprehensive guides, code examples, optimization tips, and time-saving prompts to accelerate their development workflow.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Contents

  • The 6 Core Tables at a Glance
  • Concept 1: Single-Table Polymorphism (wp_posts)
  • Concept 2: The EAV Custom Field Pattern (wp_postmeta)
  • How ACF (Advanced Custom Fields) Uses This Table
  • Concept 3: The 3-Table Taxonomy System
  • End-to-End Example: Tracing a Single Recipe
  • 1. In wp_posts (The Main Record)
  • 2. In wp_postmeta (The ACF Fields and Relationships)
  • 3. In wp_term_relationships (The Categorization)
  • How WordPress Reassembles This Data (The SQL Query)
  • Why Plugins Create Bespoke Tables
  • WordPress vs a Typed CMS: Same Data, Different Storage Model
  • FAQ
  • Wrapping Up
On this page:
  • The 6 Core Tables at a Glance
  • Concept 1: Single-Table Polymorphism (wp_posts)
  • Concept 2: The EAV Custom Field Pattern (wp_postmeta)
  • Concept 3: The 3-Table Taxonomy System
  • End-to-End Example: Tracing a Single Recipe
Build with Matija logo

Build with Matija

Senior-led B2B websites, applications, content systems, and digital infrastructure. Business-first, full-stack, AI-assisted, no handoffs.

Services

  • B2B Website Development
  • CMS Architecture Review & Platform Blueprint
  • Next.js + Payload Advisory
  • AI Integration & Implementation

Resources

  • CMS Hub
  • B2B Website Strategy
  • E-commerce Hub
  • Blog
  • Case Studies

Payload CMS

  • Payload CMS Developer
  • Payload CMS Migration
  • Payload CMS Demos
  • All Payload CMS Resources

Discuss your project

Planning a rebuild, migration, application, workflow change, or platform decision? Start with the business problem and the system behind it.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

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.

IDpost_titlepost_name (slug)post_typeWhat it represents
4821Best Practices for Server-Side Renderingbest-practices-server-side-renderingpostA standard blog article
512About Usabout-uspageA static landing page
4815hero-banner-desktop.jpghero-banner-desktop-jpgattachmentAn uploaded image in the Media Library
3390Classic Margherita Pizzaclassic-margherita-pizzarecipesA Custom Post Type (CPT)
3388Ceramic Coffee Mugceramic-coffee-mugproductsA Custom Post Type (CPT)
3512Autumn Trail Run 10Kautumn-trail-run-10keventA Custom Post Type (CPT)
2201Customer Spotlight: Dana R.customer-spotlight-dana-rstoriesA Custom Post Type (CPT)
1150Shop Nowshop-nownav_menu_itemA 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_idpost_idmeta_keymeta_value
50013390servings4 servings
50023390preparation_time25 minutes
50033390recipe_authorJordan Blake
50043390_thumbnail_id3392 (Points to another row in wp_posts)
50053390related-productsa: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.

code
1. wp_terms               2. wp_term_taxonomy                  3. wp_term_relationships
+--------------------+    +-------------------------------+    +---------------------------+
| term_id: 210       |    | term_taxonomy_id: 210         |    | object_id: 3390 (Post ID) |
| name: 'Italian'    |<---| term_id: 210                  |<---| term_taxonomy_id: 210     |
| slug: 'italian'    |    | taxonomy: 'cuisine'           |    +---------------------------+
+--------------------+    | parent: 0                     |
                          | count: 84                     |
                          +-------------------------------+

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)

  • servings → "4 servings"
  • preparation_time → "25 minutes"
  • ingredients → "<ul><li>1 pizza dough ball</li><li>San Marzano tomatoes</li></ul>"
  • directions → "<ol><li>Preheat oven to 260C...</li><li>Top and bake...</li></ol>"
  • recipe_author → "Jordan Blake"
  • author_job_title → "Recipe Developer"
  • author_image → "3391" (Attachment ID for the author headshot)
  • recipe_pdf_attachment → "https://cdn.example-recipes.com/downloads/margherita-pizza.pdf"
  • _thumbnail_id → "3392" (Attachment ID for the hero shot)
  • related-products → a:1:{i:0;s:4:"3395";} (Serialized Post ID pointing to Product 3395, Cast Iron Pizza Stone)

3. In wp_term_relationships (The Categorization)

  • object_id: 3390 ↔ term_taxonomy_id: 210 → Taxonomy cuisine: Italian
  • object_id: 3390 ↔ term_taxonomy_id: 211 → Taxonomy dietary: Vegetarian
  • object_id: 3390 ↔ term_taxonomy_id: 150 → Taxonomy meal_type: Dinner
  • 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 Fields
LEFT JOIN wp_postmeta pm_servings ON p.ID = pm_servings.post_id AND pm_servings.meta_key = 'servings'
LEFT JOIN wp_postmeta pm_time ON p.ID = pm_time.post_id AND pm_time.meta_key = 'preparation_time'
LEFT JOIN wp_postmeta pm_author ON p.ID = pm_author.post_id AND pm_author.meta_key = 'recipe_author'
-- Join Taxonomies
LEFT JOIN wp_term_relationships tr ON p.ID = tr.object_id
LEFT JOIN wp_term_taxonomy tt ON tr.term_taxonomy_id = tt.term_taxonomy_id AND tt.taxonomy = 'dietary'
LEFT JOIN wp_terms t ON tt.term_id = t.term_id
WHERE p.post_name = 'classic-margherita-pizza' AND p.post_type = 'recipes'
GROUP BY 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

WordPress Architectural ModelModern Payload CMS Model
Single monolithic table (wp_posts)Modular, typed Collections (Recipes, Products, Blogs)
Unindexed key-value pairs (wp_postmeta)Native typed columns and fields (text, number, richText)
3-table taxonomy junction (wp_terms)Dedicated Category collections with relationship fields
Images stored as posts (attachment)Dedicated Media collection with DAM integration
Serialized PHP strings in postmetaStructured JSON (Lexical RichText)

If you're weighing a move away from EAV toward typed fields, this walkthrough of how Payload CMS stores structured, localized field data shows what that looks like on a real collection. Once the schema side is decided, the self-hosted Payload CMS with Next.js deployment guide covers what running that stack in production looks like.

FAQ

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.

Thanks, Matija