---
title: "WordPress Database Structure: wp_posts & wp_postmeta"
slug: "wordpress-database-structure-wp-posts-wp-postmeta"
published: "2026-08-13"
updated: "2026-08-18"
validated: "2026-08-18"
categories:
  - "Tools"
tags:
  - "WordPress database structure"
  - "wp_posts"
  - "wp_postmeta"
  - "wp_terms"
  - "EAV pattern"
  - "Advanced Custom Fields"
  - "wp_term_taxonomy"
  - "wp_term_relationships"
  - "WordPress taxonomy"
  - "MySQL performance"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "wordpress"
  - "mysql"
  - "acf (advanced custom fields)"
  - "php"
  - "sql"
status: "stable"
llm-purpose: "WordPress database structure: trace a Recipe post through wp_posts, wp_postmeta and taxonomy tables to learn EAV, ACF behavior, SQL joins and migration…"
llm-prereqs:
  - "Access to WordPress"
  - "Access to MySQL"
  - "Access to ACF (Advanced Custom Fields)"
  - "Access to PHP"
  - "Access to SQL"
llm-outputs:
  - "Completed outcome: WordPress database structure: trace a Recipe post through wp_posts, wp_postmeta and taxonomy tables to learn EAV, ACF behavior, SQL joins and migration…"
---

**Summary Triples**
- (wp_posts, stores, every content row (posts, pages, attachments, custom post types) with post_type distinguishing types)
- (wp_postmeta, stores, custom fields as one meta_key/meta_value row per attribute linked by post_id (EAV))
- (ACF fields, are persisted, in wp_postmeta; relationship fields store IDs, repeater/group fields create indexed meta keys, and field definitions use field_ prefixed keys)
- (taxonomies, use tables, wp_terms, wp_term_taxonomy and wp_term_relationships to attach terms to posts via joins)
- (Typical WP reassembly query, joins, wp_posts -> wp_postmeta (LEFT JOIN) and taxonomy joins to reconstruct a post and its fields)
- (Serialized meta_value, may contain, PHP-serialized arrays/objects (common for complex ACF fields) and require deserialization before mapping to typed CMS)
- (Migration to typed CMS, requires, mapping meta_key names to target schema, transforming serialized meta to JSON/objects, and migrating taxonomy relationships to collections/refs)
- (Performance risk, occurs when, using many meta_query conditions or joins on wp_postmeta without denormalization or proper indexing)
- (Best practice for heavy structured data, is, move high-cardinality or structured fields to custom tables or typed collections instead of storing in wp_postmeta)

### {GOAL}
WordPress database structure: trace a Recipe post through wp_posts, wp_postmeta and taxonomy tables to learn EAV, ACF behavior, SQL joins and migration…

### {PREREQS}
- Access to WordPress
- Access to MySQL
- Access to ACF (Advanced Custom Fields)
- Access to PHP
- Access to SQL

### {STEPS}
1. Overview of core tables
2. Single-table polymorphism (wp_posts)
3. EAV custom fields (wp_postmeta)
4. Taxonomy 3-table model
5. End-to-end Recipe trace
6. Reassembly SQL and migration tips

<!-- llm:goal="WordPress database structure: trace a Recipe post through wp_posts, wp_postmeta and taxonomy tables to learn EAV, ACF behavior, SQL joins and migration…" -->
<!-- llm:prereq="Access to WordPress" -->
<!-- llm:prereq="Access to MySQL" -->
<!-- llm:prereq="Access to ACF (Advanced Custom Fields)" -->
<!-- llm:prereq="Access to PHP" -->
<!-- llm:prereq="Access to SQL" -->
<!-- llm:output="Completed outcome: WordPress database structure: trace a Recipe post through wp_posts, wp_postmeta and taxonomy tables to learn EAV, ACF behavior, SQL joins and migration…" -->

# WordPress Database Structure: wp_posts & wp_postmeta
> WordPress database structure: trace a Recipe post through wp_posts, wp_postmeta and taxonomy tables to learn EAV, ACF behavior, SQL joins and migration…
Matija Žiberna · 2026-08-13

*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

```mermaid
graph TD
  subgraph Content & Custom Fields [Entity-Attribute-Value Storage]
    POSTS["1. wp_posts<br/>(The Master Table: all content objects live here)"]
    META["2. wp_postmeta<br/>(Key-value pairs for custom fields, ACF, image IDs)"]
  end

  subgraph Taxonomies & Categorization [Normalized 3-Table Junction]
    TERMS["3. wp_terms<br/>(Names and slugs, e.g. 'Italian', 'Vegetarian')"]
    TT["4. wp_term_taxonomy<br/>(Defines taxonomy type, e.g. 'cuisine', 'dietary')"]
    REL["5. wp_term_relationships<br/>(Junction: wp_posts.ID <--> term_taxonomy_id)"]
    TM["6. wp_termmeta<br/>(Extra metadata on categories and tags)"]
  end

  POSTS -->|One-to-Many| META
  POSTS -->|Many-to-Many| REL
  REL --> TT
  TT --> TERMS
  TERMS --> TM
```

## 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.

```
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.

```mermaid
sequenceDiagram
  autonumber
  participant Posts as wp_posts
  participant Meta as wp_postmeta
  participant Rel as wp_term_relationships
  participant TT as wp_term_taxonomy
  participant Terms as wp_terms

  Note over Posts: 1. Main Content Record
  Posts->>Posts: ID: 3390, title: 'Classic Margherita Pizza', post_type: 'recipes'

  Note over Meta: 2. Custom & ACF Fields
  Posts->>Meta: post_id: 3390 -> servings = '4 servings'
  Posts->>Meta: post_id: 3390 -> preparation_time = '25 minutes'
  Posts->>Meta: post_id: 3390 -> _thumbnail_id = 3392 (Hero Image)
  Posts->>Meta: post_id: 3390 -> related-products = ['3395']

  Note over Rel,Terms: 3. Taxonomy Classifications
  Posts->>Rel: object_id: 3390 <-> term_taxonomy_id: 210
  Rel->>TT: term_taxonomy_id: 210 -> taxonomy: 'cuisine'
  TT->>Terms: term_id: 210 -> name: 'Italian'

  Posts->>Rel: object_id: 3390 <-> term_taxonomy_id: 150
  Rel->>TT: term_taxonomy_id: 150 -> taxonomy: 'meal_type'
  TT->>Terms: term_id: 150 -> name: 'Dinner'
```

### 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 Model | Modern 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 postmeta | Structured JSON (Lexical RichText) |

If you're weighing a move away from EAV toward typed fields, <a href="/blog/automating-payload-cms-translations-openai-job-queues">this walkthrough of how Payload CMS stores structured, localized field data</a> shows what that looks like on a real collection. Once the schema side is decided, <a href="/blog/deploy-payload-cms-nextjs-16-self-hosted">the self-hosted Payload CMS with Next.js deployment guide</a> 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

## LLM Response Snippet
```json
{
  "goal": "WordPress database structure: trace a Recipe post through wp_posts, wp_postmeta and taxonomy tables to learn EAV, ACF behavior, SQL joins and migration…",
  "responses": [
    {
      "question": "What does the article \"WordPress Database Structure: wp_posts & wp_postmeta\" cover?",
      "answer": "WordPress database structure: trace a Recipe post through wp_posts, wp_postmeta and taxonomy tables to learn EAV, ACF behavior, SQL joins and migration…"
    }
  ]
}
```