The WordPress Schema
The twelve tables a WordPress 6.8 install creates, what lives in each, and why you never hard-code the wp_ prefix.
$wpdb->posts // shorthand for $wpdb->prefix . 'posts'A standard single-site WordPress 6.8 install creates twelve tables. Everything the CMS knows — content, taxonomy, users, settings — lives in them, and nothing more is added unless a plugin asks for it. The schema is defined in wp-admin/includes/schema.php.
What is in each
wp_posts— posts, pages, revisions, menu items and attachments together in one table, separated by thepost_typecolumn. Indexed ontype_status_date.wp_postmeta— key/value rows joined bypost_id. There is no index onmeta_value, which is why meta queries degrade.wp_options— settings and transients, keyed byoption_name(varchar(191), unique) plus anautoloadcolumn.wp_users,wp_usermeta— accounts and their metadata. Roles and capabilities are rows in usermeta, not a table.wp_terms,wp_term_taxonomy,wp_term_relationships,wp_termmeta— the four-table taxonomy system. A term's name is separate from the taxonomy it belongs to.wp_comments,wp_commentmeta,wp_links— comments, and the Link Manager that has been hidden since 3.5 but is still created.
The prefix
$table_prefix in wp-config.php sets the prefix; wp_ is only the default. Hard-coding it breaks on any site that changed it. Read the name off $wpdb instead: $wpdb->posts is shorthand for $wpdb->prefix . 'posts' and produces the identical string.
Note the mixed column-naming conventions — ID in posts and users, comment_ID in comments, term_id in terms. They are historical and MySQL will not forgive a guess.
Example
global $wpdb;
// Shorthand: wpdb exposes one property per core table, already prefixed.
$posts_table = $wpdb->posts; // e.g. "wp_posts"
// Longhand: the same string, assembled by hand. These are identical.
$posts_table = $wpdb->prefix . 'posts';
// Your own tables have no property, so the longhand is the only form.
$events_table = $wpdb->prefix . 'acme_events';
// post_type is a column, not a table: "all published pages" is a WHERE clause.
$page_count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->posts}
WHERE post_type = 'page' AND post_status = 'publish'"
);
// Revisions and menu items live in wp_posts too - this is why a raw
// SELECT COUNT(*) FROM wp_posts is always larger than the admin says.
$breakdown = $wpdb->get_results(
"SELECT post_type, post_status, COUNT(*) AS total
FROM {$wpdb->posts}
GROUP BY post_type, post_status
ORDER BY total DESC"
);When to use it
- Writing a reporting query that WP_Query cannot express, without guessing column names.
- Auditing a slow site by measuring table row counts and autoloaded option size directly.
- Deciding whether new data belongs in postmeta or in a table of its own.
- Cleaning up orphaned postmeta or term relationships left behind by a removed plugin.
More examples
The postmeta join, written out
Shows the shape core uses for all post metadata and why meta_key is indexed while meta_value is not. Reach for this when you are deciding whether a piece of data belongs in postmeta or its own table.
global $wpdb;
// Post meta is an EAV table: one row per key, joined back on post_id.
// This is the query get_post_meta() saves you from writing.
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT p.ID, p.post_title, pm.meta_key, pm.meta_value
FROM {$wpdb->posts} AS p
LEFT JOIN {$wpdb->postmeta} AS pm ON pm.post_id = p.ID
WHERE p.ID = %d",
$post_id
)
);
// Filtering on meta_value has no index to use, so this scans every
// matching meta_key row. Fine for hundreds, painful for millions.
$expensive = $wpdb->get_col(
"SELECT post_id FROM {$wpdb->postmeta}
WHERE meta_key = 'acme_price' AND meta_value > 100"
);Walking the four taxonomy tables
The taxonomy tables are the least intuitive part of the schema. Use this when debugging why a term count is wrong, or when writing a reporting query that WP_Query's tax_query cannot express.
global $wpdb;
// terms holds the name and slug; term_taxonomy says which taxonomy that
// term belongs to; term_relationships links it to a post via object_id.
$post_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT tr.object_id
FROM {$wpdb->term_relationships} AS tr
INNER JOIN {$wpdb->term_taxonomy} AS tt
ON tt.term_taxonomy_id = tr.term_taxonomy_id
INNER JOIN {$wpdb->terms} AS t
ON t.term_id = tt.term_id
WHERE tt.taxonomy = %s AND t.slug = %s",
'product_cat',
'clearance'
)
);
// Note the join key: term_relationships stores term_taxonomy_id, NOT term_id.
// Joining on term_id is the single most common mistake in this schema.Inspecting what loads on every request
A diagnostic worth keeping in a snippet file. Run it when a site has a slow time-to-first-byte on every page including cached ones — a multi-megabyte alloptions payload is a common cause.
global $wpdb;
// wp_options is read on every single page load for autoloaded rows.
// This tells you what that costs on a site that feels slow.
$heaviest = $wpdb->get_results(
"SELECT option_name, LENGTH(option_value) AS bytes
FROM {$wpdb->options}
WHERE autoload IN ('yes','on','auto-on','auto')
ORDER BY bytes DESC
LIMIT 20"
);
// WordPress 6.6 introduced the 'auto', 'auto-on' and 'auto-off' values
// alongside the historic 'yes' and 'no', so match on all of them.
$total = (int) $wpdb->get_var(
"SELECT SUM(LENGTH(option_value)) FROM {$wpdb->options}
WHERE autoload IN ('yes','on','auto-on','auto')"
);
Discussion