Database Schema
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
WordPress keeps content, users, taxonomies, comments and settings in a small, fixed set of MySQL or MariaDB tables. Most plugins add no tables: custom content becomes posts of a custom post type, extra fields become metadata rows and settings become options. The schema explains why some queries are slow, what a backup and a migration must handle, and why a Multisite network has so many tables.
This page was written against WordPress 7.1.3 (released 2026-10-06; database version 61833; 7.1.4, a security release
of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, MariaDB 11.8 and WP-CLI
2.12.0, and checked on 2026-10-28. Every CREATE TABLE statement and every
query result below comes from a fresh 7.1.3 installation, later converted to Multisite. WordPress 7.2 (in beta, planned for
2026-12-08) adds options but no table changes so far. The requirements page recommends MySQL 8.0+ or MariaDB
10.11+; the schema is the same on all supported servers.
Where the schema is defined
There is no migration framework. The canonical definition is the function wp_get_db_schema() in
wp-admin/includes/schema.php, which returns the CREATE TABLE statements for a scope (all, blog, global or
ms_global). The installer and every upgrade pass that SQL to dbDelta(), which compares it with the live tables and
adds missing tables, columns and indexes. Targeted changes dbDelta() cannot do (dropping an index, changing a column
type on Multisite tables) live in pre_schema_upgrade() and the upgrade_*() functions in
wp-admin/includes/upgrade.php, gated by the stored db_version option against $wp_db_version in
wp-includes/version.php; when the code is newer, WordPress shows "Database Update Required" (or run
wp core update-db). The old Codex "Database Description" page predates several changes below; read schema.php
for the version you run, or ask the database.
A site to inspect
The Compose file below starts MariaDB and a WP-CLI container, which downloads and installs core itself. It is the environment used for every result on this page; the passwords are placeholders for a local machine only.
# compose.yaml
name: lumen-schema
services:
db:
image: mariadb:11.8
environment:
MARIADB_DATABASE: wordpress
MARIADB_USER: wordpress
MARIADB_PASSWORD: ${WP_DB_PASSWORD:?set WP_DB_PASSWORD}
MARIADB_RANDOM_ROOT_PASSWORD: "1"
cli:
image: wordpress:cli-2.12.0-php8.4
depends_on: [db]
user: "33:33"
environment:
WP_DB_PASSWORD: ${WP_DB_PASSWORD}
WP_ADMIN_PASSWORD: ${WP_ADMIN_PASSWORD:?set WP_ADMIN_PASSWORD}
volumes: [wp:/var/www/html]
entrypoint: ["sleep", "infinity"]
volumes:
wp: {}
export WP_DB_PASSWORD='<a-local-db-password>' WP_ADMIN_PASSWORD='<a-local-admin-password>'
docker compose up -d
docker compose exec cli sh -c 'php -d memory_limit=512M /usr/local/bin/wp core download --version=7.1.3 &&
wp config create --dbname=wordpress --dbuser=wordpress --dbpass="$WP_DB_PASSWORD" --dbhost=db &&
wp core install --url=http://lumenpress.test --title="Lumen Press" --admin_user=admin \
--admin_email=admin@lumenpress.test --admin_password="$WP_ADMIN_PASSWORD" --skip-email'
docker compose exec cli wp db tables
docker compose exec cli wp db query 'SHOW CREATE TABLE wp_posts\G'
Source: WP-CLI: wp db tables
wp db tables lists only the tables registered on $wpdb; --all-tables-with-prefix adds plugin tables that share
the prefix and --all-tables lists everything in the database. On a single site the first command prints exactly the
12 tables of the next section.
The 12 single-site tables
The relationships in the diagram are logical. The tables use InnoDB but declare no foreign keys: integrity is kept
by the PHP API (wp_delete_post() deletes the post’s meta, comments and term relationships), so rows written with raw
SQL can leave orphans behind. wp_options has no relationship to the other tables.
| Table | Holds, and what to know about it |
|---|---|
|
Every piece of content: posts, pages, attachments, revisions, menus, patterns ( |
|
Key/value pairs per post: custom fields, |
|
Accounts. |
|
Per-user data: roles ( |
|
Term names and slugs. Since 4.2 a new term belongs to exactly one taxonomy; older shared terms were split. |
|
The term in a taxonomy ( |
|
Which object has which term ( |
|
Term metadata, added in 4.4. |
|
Comments, pingbacks, trackbacks and, since 6.9, editorial notes on blocks ( |
|
Comment metadata, such as spam-check results or the status before trashing. |
|
The Links (blogroll) manager, hidden since 3.5 on sites without links; the table is still created on every install. |
|
Site settings and plugin data: |
The actual definition of the content table, as MariaDB reports it after a 7.1.3 install:
CREATE TABLE `wp_posts` (
`ID` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`post_author` bigint(20) unsigned NOT NULL DEFAULT 0,
`post_date` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
`post_date_gmt` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
`post_content` longtext NOT NULL,
`post_title` text NOT NULL,
`post_excerpt` text NOT NULL,
`post_status` varchar(20) NOT NULL DEFAULT 'publish',
`comment_status` varchar(20) NOT NULL DEFAULT 'open',
`ping_status` varchar(20) NOT NULL DEFAULT 'open',
`post_password` varchar(255) NOT NULL DEFAULT '',
`post_name` varchar(200) NOT NULL DEFAULT '',
`to_ping` text NOT NULL,
`pinged` text NOT NULL,
`post_modified` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
`post_modified_gmt` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
`post_content_filtered` longtext NOT NULL,
`post_parent` bigint(20) unsigned NOT NULL DEFAULT 0,
`guid` varchar(255) NOT NULL DEFAULT '',
`menu_order` int(11) NOT NULL DEFAULT 0,
`post_type` varchar(20) NOT NULL DEFAULT 'post',
`post_mime_type` varchar(100) NOT NULL DEFAULT '',
`comment_count` bigint(20) NOT NULL DEFAULT 0,
PRIMARY KEY (`ID`),
KEY `post_name` (`post_name`(191)),
KEY `type_status_date` (`post_type`,`post_status`,`post_date`,`ID`),
KEY `post_parent` (`post_parent`),
KEY `post_author` (`post_author`),
KEY `type_status_author` (`post_type`,`post_status`,`post_author`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci
Some columns are worth knowing by name. guid is a permanent identifier for feeds and must not be rewritten during a
domain migration, even though it looks like a URL. post_content_filtered, to_ping and pinged are legacy and
almost always empty. Dates are stored twice, in site time and in UTC (_gmt), and the zero date
0000-00-00 00:00:00 is used for drafts that have no publish date yet, which is why a MySQL server in strict
NO_ZERO_DATE mode can reject WordPress’s own writes; WordPress sets a compatible SQL mode per connection.
Indexes
| Table | Indexes besides the primary key |
|---|---|
|
|
|
the parent ID column and |
|
|
|
|
|
unique |
|
|
|
|
|
unique |
|
|
The (191) prefix lengths date from the utf8mb4 conversion in 4.2: a utf8mb4 character can take 4 bytes, and the
classic InnoDB index key limit of 767 bytes fits 191 of them. schema.php still sets $max_index_length = 191 even
though modern InnoDB (DYNAMIC row format) allows 3072 bytes.
The newest index, type_status_author, was added in WordPress 6.9 to speed up queries that filter by post type,
status and author on sites with many posts. In the same release the ID columns of the Multisite
tables became unsigned to match the single-site tables.
An older handbook recommends adding an index on post_name to speed up permalink lookups. That index has existed for
as long as the table has (as post_name(191) since 4.2), so the advice adds a duplicate index that costs write time
and buys nothing; wp db query "SHOW INDEX FROM wp_posts" shows what exists before adding anything.
Adding your own index to a core table is possible, but it is not part of the schema WordPress maintains: dbDelta()
does not drop unknown indexes, a reinstall or migration tool may not carry it, and a future core index can duplicate
it. Document it and prefer the access pattern that uses the existing indexes.
The posts/postmeta model and its query cost
wp_posts has a fixed set of columns. Anything else a book needs (ISBN, page count, publication date) goes in
wp_postmeta as one row per attribute. This is an entity-attribute-value (EAV) design: it lets any plugin add fields
without altering the schema, and it makes filtering and sorting by those fields expensive. Lumen Press registers its
book meta so that it is typed, sanitized and visible in the REST API (the full plugin is on
Custom Post Types and Taxonomies and
Metadata and Custom Fields):
<?php
/**
* Plugin Name: Lumen Catalog (schema excerpt)
*/
add_action( 'init', function () {
register_post_type( 'lp_book', array(
'label' => 'Books',
'public' => true,
'show_in_rest' => true,
'supports' => array( 'title', 'editor', 'custom-fields' ),
) );
register_taxonomy( 'lp_genre', 'lp_book', array( 'label' => 'Genres', 'show_in_rest' => true ) );
$fields = array( 'lp_isbn' => 'string', 'lp_pages' => 'integer', 'lp_published_on' => 'string' );
foreach ( $fields as $key => $type ) {
register_post_meta( 'lp_book', $key, array(
'type' => $type,
'single' => true,
'show_in_rest' => true,
) );
}
} );
Registering meta does not change storage. Two books created with WP-CLI become two wp_posts rows and six
wp_postmeta rows, all values stored as text:
$ wp db query "SELECT p.ID, p.post_title, m.meta_key, m.meta_value FROM wp_posts p
JOIN wp_postmeta m ON m.post_id = p.ID WHERE p.post_type = 'lp_book' ORDER BY p.ID, m.meta_key"
ID post_title meta_key meta_value
4 The Lantern Keeper lp_isbn 978-0-00-000001-9
4 The Lantern Keeper lp_pages 312
4 The Lantern Keeper lp_published_on 2026-03-14
5 Night Ferry lp_isbn 978-1-23456-793-4
5 Night Ferry lp_pages 248
5 Night Ferry lp_published_on 2025-11-02
Source: WP-CLI: wp db query
A WP_Query for long fantasy books, sorted by page count, shows the price of the model. Save it as query-demo.php
in the WordPress root and run it with wp eval-file query-demo.php:
<?php
$q = new WP_Query( array(
'post_type' => 'lp_book',
'posts_per_page' => 10,
'meta_key' => 'lp_pages',
'orderby' => 'meta_value_num',
'order' => 'DESC',
'meta_query' => array(
array(
'key' => 'lp_pages',
'value' => 300,
'compare' => '>=',
'type' => 'NUMERIC',
),
),
'tax_query' => array(
array(
'taxonomy' => 'lp_genre',
'field' => 'slug',
'terms' => 'fantasy',
),
),
) );
echo wp_list_pluck( $q->posts, 'post_title' )[0] ?? 'none', "\n", $q->request, "\n";
Source: Code Reference: WP_Query
The generated SQL (whitespace condensed) joins wp_postmeta twice, once for the ordering key and once for the
condition, plus wp_term_relationships for the genre:
SELECT SQL_CALC_FOUND_ROWS wp_posts.ID
FROM wp_posts
LEFT JOIN wp_term_relationships ON (wp_posts.ID = wp_term_relationships.object_id)
INNER JOIN wp_postmeta ON ( wp_posts.ID = wp_postmeta.post_id )
INNER JOIN wp_postmeta AS mt1 ON ( wp_posts.ID = mt1.post_id )
WHERE 1=1
AND ( wp_term_relationships.term_taxonomy_id IN (2) )
AND ( wp_postmeta.meta_key = 'lp_pages'
AND ( ( mt1.meta_key = 'lp_pages' AND CAST(mt1.meta_value AS SIGNED) >= '300' ) ) )
AND ((wp_posts.post_type = 'lp_book' AND (wp_posts.post_status = 'publish')))
GROUP BY wp_posts.ID
ORDER BY wp_postmeta.meta_value+0 DESC
LIMIT 0, 10
Source: Code Reference: WP_Meta_Query
What makes this costly:
-
every additional meta condition adds another self-join of
wp_postmeta, the largest table on most sites; -
meta_valueislongtextand is not indexed, so theCAST(... AS SIGNED)comparison and themeta_value+0ordering are evaluated row by row for every row that matchesmeta_key; -
EXPLAINon MariaDB 11.8 shows the meta join driven by themeta_key(191)index (ref, key length 767), which narrows rows by key but not by value; -
SQL_CALC_FOUND_ROWScounts all matches for pagination; pass'no_found_rows' => truewhen you do not paginate.
On a catalogue of a few thousand books this is fine. It degrades when wp_postmeta reaches millions of rows and the
query combines several meta conditions with sorting. The usual remedies, in order: filter by taxonomy instead of meta
whenever the value is a category (genre, series, format), because term queries use proper integer indexes; keep the
number of meta conditions small; cache the result (see
Options, Transients and Object Cache); and, for data
that is queried relationally, use a custom table created with dbDelta() (see
WP_Query and the Database API). The background on normal forms
and joins is in Normalization and
DML Queries.
The same EAV pattern applies to wp_usermeta, wp_termmeta and wp_commentmeta, and on Multisite to wp_blogmeta
and wp_sitemeta. All share the get_metadata() family of functions.
Options and autoload
wp_options is a key/value store. On every request WordPress runs wp_load_alloptions(), which reads all autoloaded
options in one query and caches them; any other option costs a separate query the first time it is read (unless a
persistent object cache holds it). Large autoloaded options therefore slow every page, including pages that never use
them.
The table has four columns: option_id, option_name (varchar(191), unique), option_value (longtext) and
autoload (varchar(20), indexed).
Until 6.5 the autoload column held yes or no, and add_option() defaulted to yes. WordPress 6.6 changed the
values and the default:
| Value | Meaning |
|---|---|
|
Explicitly autoloaded ( |
|
Explicitly not autoloaded ( |
|
No explicit choice; WordPress decides. Autoloaded in 6.6 and later, but core may change that. |
|
No explicit choice and WordPress decided to autoload. |
|
No explicit choice and WordPress decided not to, because the value is larger than 150,000 bytes
(filter |
|
Legacy values, still honoured. Passing |
The set of values that count as autoloaded is yes, on, auto-on and auto, filterable with
wp_autoload_values_to_autoload (the filter can only remove values). Site Health reports a critical issue, "Autoloaded
options could affect performance", when the autoloaded total passes 800 KB. The column default in the table is still
'yes', so a row inserted by raw SQL without the column becomes autoloaded.
Lumen Catalog stores its distributor settings, read only on its own admin screen and by the stock-sync cron job, with autoload explicitly off:
<?php
add_option(
'lp_catalog_settings',
array( 'distributor_url' => 'https://distributor.example' ),
'',
false // Not autoloaded: only the settings page and the cron job read it.
);
// Switch existing options without rewriting their values (since 6.4).
wp_set_option_autoload_values(
array(
'lp_catalog_settings' => false,
'lumen_catalog_db_version' => true,
)
);
To see the distribution on a site, and the real autoloaded size (which wp option list --autoload=on understates,
because it only counts the on rows):
wp db query "SELECT autoload, COUNT(*) AS options, SUM(LENGTH(option_value)) AS bytes
FROM wp_options GROUP BY autoload"
wp db query "SELECT SUM(LENGTH(option_value)) AS autoloaded_bytes FROM wp_options
WHERE autoload IN ('yes','on','auto-on','auto')"
wp option list --autoload=on --format=total_bytes
Source: WP-CLI: wp option list
On the fresh 7.1.3 install the first query returned 100 on rows (37,166 bytes), 16 auto rows (1,508 bytes) and 10
off rows; the second returned 38,675 bytes and wp option list returned 37,166. An option of 200,000 bytes added
without an autoload argument was stored as auto-off. Note that wp option add without --autoload stores on,
unlike add_option() in PHP.
Table prefix
$table_prefix in wp-config.php (default wp_) is prepended to every table name and is exposed as
$wpdb->prefix. Code must never hard-code wp_; use the table properties ($wpdb->posts, $wpdb->options) or
{$wpdb->prefix} for custom tables:
<?php
global $wpdb;
$long_books = $wpdb->get_col(
$wpdb->prepare(
"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND CAST(meta_value AS UNSIGNED) >= %d",
'lp_pages',
300
)
);
Source: Code Reference: wpdb
The prefix may contain only letters, digits and underscores. It lets several installations share one database, and it
appears inside data as well as in table names: the wp_user_roles option and the wp_capabilities and
wp_user_level user meta keys carry it. Renaming the tables alone therefore locks every user out of the admin;
changing the prefix of an existing site means renaming the tables and updating those option and meta keys
(see Importing, Exporting and Migrating). A non-default
prefix is sometimes sold as a security measure. It only defeats the crudest automated SQL injection payloads that
assume wp_; it does nothing against an injection that can query information_schema. Fix the injection instead.
Character set and collation
Since 4.2, WordPress creates and converts tables to utf8mb4, the MySQL character set that stores all of Unicode,
including emoji (the older utf8, an alias of utf8mb3, stops at 3 bytes per character). wpdb::determine_charset()
upgrades a configured utf8 to utf8mb4 when the server supports it, and picks utf8mb4_unicode_520_ci as the
collation when available. That is why the test install above, whose wp-config.php was generated by
wp config create with DB_CHARSET 'utf8', still produced utf8mb4 tables; the wp-config-sample.php shipped with
core already says utf8mb4.
In wp-config.php, set DB_CHARSET to utf8mb4 and leave DB_COLLATE empty unless the database already uses a
specific collation. Mismatched collations between a custom table and a core table produce "Illegal mix of
collations" errors in joins; create custom tables with
$wpdb->get_charset_collate(). When moving a database between servers, make sure the dump and the import both use
utf8mb4, otherwise accented characters and emoji turn into mojibake.
Multisite tables
Converting the site to a network (subdirectories, wp core multisite-convert) and creating the two imprint sites
Lumen Kids and Lumen Noir adds six network tables and one set of ten tables per new site:
wp core multisite-convert --title="Lumen Press Network"
wp site create --slug=kids --title="Lumen Kids"
wp site create --slug=noir --title="Lumen Noir"
wp db tables --network
wp db tables --scope=blog --url=lumenpress.test/kids/
| Table | Holds |
|---|---|
|
Networks: one row per network ( |
|
Network options: |
|
Sites: |
|
Site metadata (since 5.1), read and written with |
|
Pending user and site registrations with their activation keys. |
|
Who registered which site, from which IP address. |
The naming is historical: in the database a "site" (wp_site) is a network and a "blog" (wp_blogs) is a site, the
opposite of the admin screens. After the commands above, wp_blogs holds blog IDs 1 (/), 2 (/kids/) and 3
(/noir/), all with site_id 1.
The table layout follows these rules:
-
wp_usersandwp_usermetaare global: one account works on every site, and roles are stored per site aswp_2_capabilities,wp_3_capabilitiesinwp_usermeta. On Multisitewp_usersgainsspamanddeleted. -
The main site keeps the base prefix (
wp_posts,wp_options), and$wpdb->get_blog_prefix(1)returnswp_. -
Every other site gets ten tables prefixed
wp_N_(wp_2_posts,wp_2_options, …,wp_2_links): the single-site list withoutwp_usersandwp_usermeta. The count has been ten sincetermmetaarrived in 4.4. -
$wpdb->prefixchanges withswitch_to_blog();$wpdb->base_prefixdoes not. Network-level code that builds table names must usebase_prefixfor global tables.
So a network with 100 sites has 6 network tables, the 12 tables of the main site and 99 × 10 = 990 more: 1,008 in
all. Backups, information_schema queries and some managed database plans with table limits feel that number.
Multisite table counts in older administration books predate wp_blogmeta (5.1) or termmeta (4.4); current
versions create six network tables, share the users tables, and add ten tables per additional site. The domain-mapping
plugin some of them describe is not needed either: since 4.5, a site’s domain is the domain column of wp_blogs,
edited from Network Admin. Multisite setup and operation are on Multisite Networks,
and code that must work across sites on Multisite Development.
The conversion also writes MULTISITE, SUBDOMAIN_INSTALL, DOMAIN_CURRENT_SITE, PATH_CURRENT_SITE,
SITE_ID_CURRENT_SITE (a wp_site.id) and BLOG_ID_CURRENT_SITE (the main site’s wp_blogs.blog_id) to
wp-config.php, as described in
Create A Network.
Common errors and pitfalls
| Symptom | Cause and fix |
|---|---|
The installer appears, or administrators get "Sorry, you are not allowed to access this page", after changing |
WordPress finds no options table with the new prefix, or user roles are lost because the |
"Database Update Required" after a core update |
|
Slow pages and Site Health "Autoloaded options could affect performance" |
Large autoloaded options, often left by deactivated plugins. Find them by size and set autoload to off with
|
Slow admin lists or archives filtered by custom fields |
Multiple |
Orphaned |
Rows deleted with raw SQL instead of the API; there are no foreign keys. Delete through |
Broken serialized data after a search-and-replace migration |
A plain SQL |
"Illegal mix of collations" in a plugin query |
A custom table created without |
Related pages
wp-config.php Reference, WP_Query and the Database API, Options, Transients and Object Cache, Performance and Caching, Backups and Disaster Recovery, WP-CLI, DDL and Redis (the persistent object cache that relieves option and meta lookups). WooCommerce adds its own order and lookup tables; they are covered in Architecture and HPOS: High-Performance Order Storage.