
What is the WordPress database structure?
The WordPress database structure is a set of 12 core MySQL (or MariaDB) tables that store everything on your site except files: posts, pages, users, comments, settings, menus, categories, tags, and the extra data attached to each of them. Your theme, plugins, and uploaded media live on the server's file system, but every piece of content and configuration that WordPress displays or remembers is read from these tables on each request.
Understanding how these tables fit together makes you a much more confident WordPress user. It helps you debug problems, write better custom queries, evaluate plugins that bloat your database, and recover data when something goes wrong. In this article, you'll learn what each core table does, how the tables relate to one another, how to explore them safely, and how to query them the right way from your own code.
What Is the WordPress Database?
WordPress is a PHP application that stores its data in a relational database. On nearly every host, that database is MySQL or its drop-in replacement MariaDB. When you installed WordPress, you gave it four pieces of information in wp-config.php: the database name, the database user, the password, and the host. WordPress then created its tables inside that database during the famous five-minute install.
Here's what those settings look like in wp-config.php:
define( 'DB_NAME', 'my_wordpress_db' );
define( 'DB_USER', 'my_db_user' );
define( 'DB_PASSWORD', 'a-strong-password' );
define( 'DB_HOST', 'localhost' );
define( 'DB_CHARSET', 'utf8mb4' );
define( 'DB_COLLATE', '' );
$table_prefix = 'wp_';
Every time someone loads a page, WordPress connects to the database, reads your site settings, figures out which content was requested, and runs queries to fetch it. The global $wpdb object handles this connection and gives developers a safe way to run their own queries.
The Table Prefix
Notice the $table_prefix line. By default, every WordPress table starts with wp_, so the posts table is wp_posts, the users table is wp_users, and so on. Many hosts and installers change this prefix to something random, such as wp8x3_, which is fine. Throughout this article, I'll use wp_ for readability, but your tables may look different.
The prefix also lets several WordPress installs share a single database without their tables colliding, which is common on cheaper hosting plans that limit the number of databases you can create.
The 12 Core WordPress Database Tables
A fresh single-site WordPress installation creates 12 tables. They fall naturally into a few groups: content, users, comments, taxonomies, settings, and links.
| Table | What It Stores |
|---|---|
wp_posts | Posts, pages, attachments, revisions, menu items, custom post types, templates |
wp_postmeta | Extra key-value data attached to posts |
wp_users | User accounts and login details |
wp_usermeta | Extra key-value data attached to users, including roles |
wp_comments | Comments, pingbacks, and trackbacks |
wp_commentmeta | Extra key-value data attached to comments |
wp_terms | Individual categories, tags, and custom taxonomy terms |
wp_term_taxonomy | Which taxonomy each term belongs to, plus descriptions and counts |
wp_term_relationships | Which posts are assigned to which terms |
wp_termmeta | Extra key-value data attached to terms |
wp_options | Site-wide settings, plugin settings, and transients |
wp_links | The legacy Link Manager (blogroll) data |
Let's go through each group in detail.
Content Tables: wp_posts and wp_postmeta
wp_posts
wp_posts is the heart of WordPress. Despite its name, it stores far more than blog posts. Every row has a post_type column that tells WordPress what kind of content it is:
postfor regular blog postspagefor static pagesattachmentfor files in the Media Libraryrevisionfor saved revisions of posts and pagesnav_menu_itemfor individual items in your navigation menuswp_templateandwp_template_partfor block theme templates edited in the Site Editorwp_global_stylesfor customizations made in Appearance > Editor > Styleswp_blockfor synced patterns (formerly reusable blocks)wp_navigationfor Navigation block menus- Any custom post type registered by your theme or plugins, such as
productfor WooCommerce
The most important columns are:
- ID: The unique numeric identifier for the row.
- post_author: The ID of the user who created it, pointing to
wp_users.ID. - post_date and post_date_gmt: When the content was published, in local time and GMT.
- post_content: The actual content. For block editor content, this is HTML with block comments like
<!-- wp:paragraph -->. - post_title, post_excerpt, and post_name: The title, excerpt, and slug.
- post_status: Values like
publish,draft,pending,private,future,trash,auto-draft, andinherit(used by revisions and attachments). - post_parent: The ID of a parent row. Pages use this for hierarchy, attachments use it to link to the post they were uploaded to, and revisions use it to point to the original post.
- menu_order: A number used for manual ordering, for example of pages or menu items.
- guid: A globally unique identifier. It looks like a URL, but it's not meant to be used as one and shouldn't be changed after publishing.
Because so many things live in wp_posts, this table is usually the largest on a busy site, and revisions are often the main reason it grows.
wp_postmeta
wp_postmeta stores extra information about posts in a flexible key-value format. It has just four columns:
- meta_id: A unique ID for the row.
- post_id: The ID of the post this data belongs to.
- meta_key: The name of the piece of data.
- meta_value: The value, stored as text. Arrays and objects are stored in PHP's serialized format.
This design is what makes WordPress so extensible. When you set a featured image, WordPress stores _thumbnail_id in postmeta. When you pick a page template, it saves _wp_page_template. When an SEO plugin saves a custom meta description, or WooCommerce stores a product's price, it all goes into postmeta. Keys beginning with an underscore are "protected" and hidden from the Custom Fields panel in the editor.
The downside of this flexibility is that wp_postmeta can become enormous and slow to search, since meta_value isn't indexed. That's why WooCommerce moved orders to its own dedicated tables with High-Performance Order Storage (HPOS).
User Tables: wp_users and wp_usermeta
wp_users
wp_users stores the basic account for each person who can log in. Its key columns are:
- ID: The user's unique ID.
- user_login: The username used to log in. WordPress doesn't let you change it from the dashboard.
- user_pass: A hashed password. WordPress never stores plain-text passwords. Since WordPress 6.8, new hashes use bcrypt by default, while older phpass hashes are still accepted and upgraded on login.
- user_nicename: A URL-friendly version of the username, used in author archive URLs.
- user_email, user_url, user_registered, and display_name: Contact and profile basics.
wp_usermeta
Just like postmeta, wp_usermeta holds everything else about a user in key-value pairs. This includes first and last name, biographical info, admin color scheme, whether the toolbar is shown, and dashboard preferences.
Most importantly, user roles live here. The key is wp_capabilities (using your table prefix), and the value is a serialized array like a:1:{s:13:"administrator";b:1;}. There's also wp_user_level, a legacy value kept for backward compatibility. If you ever change your table prefix, you must update these meta keys too, or every user will lose their role.
Comment Tables: wp_comments and wp_commentmeta
wp_comments stores every comment, pingback, and trackback. Each row includes:
- comment_post_ID: The post the comment belongs to.
- comment_author, comment_author_email, comment_author_url, and comment_author_IP: Details about the commenter.
- comment_content: The comment text.
- comment_approved:
1for approved,0for pending,spamfor spam, andtrashfor trashed comments. - comment_parent: The ID of the parent comment for threaded replies.
- user_id: The ID of a logged-in commenter, or
0for guests. - comment_type: Usually
comment, but alsopingback,trackback, or custom types added by plugins.
wp_commentmeta stores extra data about comments. Anti-spam plugins like Akismet use it to record their spam-check history, which can add up to a lot of rows on busy sites.
Taxonomy Tables: How Categories and Tags Work
Taxonomies are the part of the database structure that confuses people most, because WordPress splits them across four tables. Here's how they work together.
wp_terms
wp_terms stores the terms themselves: their term_id, name (such as "WordPress Tips"), and slug (such as wordpress-tips). On its own, a term doesn't know whether it's a category, a tag, or something else.
wp_term_taxonomy
wp_term_taxonomy gives the term its context. Each row links a term_id to a taxonomy like category, post_tag, nav_menu, wp_theme, or a custom taxonomy like product_cat. It also stores:
- term_taxonomy_id: The unique ID for this term-in-a-taxonomy combination.
- description: The description you enter for a category or tag.
- parent: The parent term's ID, used for hierarchical taxonomies like categories.
- count: How many objects are assigned to the term.
wp_term_relationships
wp_term_relationships is a join table. Each row connects an object_id (usually a post ID) with a term_taxonomy_id. That's how WordPress knows that post 42 is in the "Tutorials" category and tagged "database". Note that it uses term_taxonomy_id, not term_id.
wp_termmeta
wp_termmeta stores extra data for terms, such as a category image or custom color added by a theme or plugin. It was added in WordPress 4.4.
Putting It Together
To find all published posts in the "Tutorials" category, WordPress effectively joins these tables like this:
SELECT p.ID, p.post_title
FROM wp_posts AS p
INNER JOIN wp_term_relationships AS tr ON p.ID = tr.object_id
INNER JOIN wp_term_taxonomy AS tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
INNER JOIN wp_terms AS t ON tt.term_id = t.term_id
WHERE tt.taxonomy = 'category'
AND t.slug = 'tutorials'
AND p.post_type = 'post'
AND p.post_status = 'publish'
ORDER BY p.post_date DESC;
You'll rarely need to write this yourself, because WP_Query builds these joins for you when you pass a category_name or tax_query argument.
Settings Table: wp_options
wp_options stores site-wide settings as name-value pairs. It has four columns: option_id, option_name, option_value, and autoload.
Your site URL (siteurl and home), site title (blogname), tagline (blogdescription), active theme (template and stylesheet), active plugins (active_plugins), permalink structure, and nearly every setting under the Settings menu live here. Plugins store their settings here too, and it's also where transients are kept when you don't have a persistent object cache.
The Autoload Column
The autoload column decides whether an option is loaded into memory on every single page request. WordPress fetches all autoloaded options in one query early in the load process. Historically the values were yes and no. Since WordPress 6.6, you'll also see values like on, off, auto, auto-on, and auto-off, and WordPress avoids autoloading very large options by default.
If plugins autoload lots of large data, every page gets slower. WordPress's Tools > Site Health screen will warn you when autoloaded options grow too large. You can check the total size yourself with a query like this:
SELECT SUM(LENGTH(option_value)) AS autoload_bytes
FROM wp_options
WHERE autoload IN ('yes', 'on', 'auto-on', 'auto');
The Legacy Table: wp_links
wp_links stores data for the old Link Manager, the blogroll feature that was hidden by default in WordPress 3.5. The table is still created on new installs for backward compatibility, but it's usually empty. You can re-enable the Link Manager with the official Link Manager plugin if you really need it, but most sites never touch this table.
Multisite Database Tables
If you run a WordPress Multisite network, the structure changes. The network shares the wp_users and wp_usermeta tables, and WordPress adds several network-level tables:
- wp_blogs: One row per site in the network.
- wp_blogmeta: Extra data about each site.
- wp_site and wp_sitemeta: The network itself and its network-wide settings.
- wp_signups: Pending user and site registrations.
- wp_registration_log: A record of sites created via registration.
Each additional site gets its own copy of the content and settings tables, with the site ID in the prefix. Site 2 uses wp_2_posts, wp_2_options, wp_2_comments, and so on.
Tables Added by Plugins
Many plugins create their own tables when a key-value meta table isn't a good fit. Some well-known examples include:
- WooCommerce: Tables like
wp_wc_orders,wp_wc_order_addresses,wp_wc_product_meta_lookup, andwp_woocommerce_sessions. - Action Scheduler (bundled with WooCommerce and many other plugins):
wp_actionscheduler_actionsand related tables for background jobs. - Yoast SEO:
wp_yoast_indexableand related tables for its indexing system. - Wordfence: Several
wp_wf*tables for firewall and scan data. - Redirection:
wp_redirection_itemsand log tables.
When you delete a plugin, it doesn't always clean up its tables. Leftover tables from plugins you no longer use are one of the most common sources of database clutter.
How the Tables Relate to Each Other
WordPress doesn't use formal foreign key constraints in MySQL. Instead, relationships are enforced by WordPress's own PHP code. The main connections are:
wp_posts.post_authorpoints towp_users.ID.wp_postmeta.post_idpoints towp_posts.ID.wp_posts.post_parentpoints to another row inwp_posts.wp_comments.comment_post_IDpoints towp_posts.ID, andwp_comments.user_idpoints towp_users.ID.wp_commentmeta.comment_idpoints towp_comments.comment_ID.wp_usermeta.user_idpoints towp_users.ID.wp_term_taxonomy.term_idpoints towp_terms.term_id.wp_term_relationships.term_taxonomy_idpoints towp_term_taxonomy.term_taxonomy_id, andobject_idpoints to a post (or link) ID.wp_termmeta.term_idpoints towp_terms.term_id.
Because there are no database-level constraints, deleting rows directly in phpMyAdmin can leave "orphaned" data behind, such as postmeta for posts that no longer exist. That's one reason you should use WordPress functions like wp_delete_post() rather than raw DELETE statements.
How to View Your WordPress Database
You have several ways to look inside your database. Always take a full backup before you change anything.
-
phpMyAdmin: Most hosting control panels, including cPanel and Plesk, include phpMyAdmin. Open it, select your WordPress database in the left sidebar, and you'll see all the tables. The Browse tab shows rows, and the Structure tab shows columns.
-
Adminer: A lightweight single-file alternative to phpMyAdmin. Some hosts provide it, and you can upload it temporarily yourself. Delete it when you're done.
-
Desktop clients: Tools like TablePlus, HeidiSQL, DBeaver, or MySQL Workbench can connect over SSH to your server's database.
-
WP-CLI: If you have SSH access, WP-CLI is the fastest option.
Here are a few useful WP-CLI commands:
# List all tables that belong to this WordPress install
wp db tables
# Show the size of each table
wp db size --tables
# Run a query and print the results
wp db query "SELECT option_name, option_value FROM wp_options WHERE option_name IN ('siteurl', 'home');"
# Export a full backup before making changes
wp db export backup-before-changes.sql
Note that wp db query doesn't know your prefix automatically, so write the real table names. You can find your prefix with wp config get table_prefix.
How to Query the Database Safely in Code
When you write custom code, you should almost always use WordPress's own APIs instead of raw SQL. Functions like get_posts(), WP_Query, get_post_meta(), get_user_meta(), get_option(), and get_terms() handle caching, filters, and security for you.
For example, to get the five most recent published posts that have a specific custom field:
$sajjad_query = new WP_Query(
array(
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => 5,
'meta_key' => 'featured_story',
'meta_value' => 'yes',
)
);
if ( $sajjad_query->have_posts() ) {
while ( $sajjad_query->have_posts() ) {
$sajjad_query->the_post();
echo '<h2>' . esc_html( get_the_title() ) . '</h2>';
}
wp_reset_postdata();
}
Using $wpdb for Custom Queries
Sometimes you do need a custom query, for example for a report or a custom plugin table. In that case, use the global $wpdb object, reference tables through its properties so the prefix is always correct, and always use $wpdb->prepare() for any value that comes from outside your code:
function sajjad_count_posts_by_author( $user_id ) {
global $wpdb;
$count = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->posts}
WHERE post_author = %d
AND post_type = %s
AND post_status = %s",
absint( $user_id ),
'post',
'publish'
)
);
return (int) $count;
}
The $wpdb object exposes properties for every core table: $wpdb->posts, $wpdb->postmeta, $wpdb->users, $wpdb->usermeta, $wpdb->comments, $wpdb->commentmeta, $wpdb->terms, $wpdb->term_taxonomy, $wpdb->term_relationships, $wpdb->termmeta, $wpdb->options, and $wpdb->links. For your own tables, use $wpdb->prefix . 'my_table'.
Creating a Custom Table in a Plugin
If your plugin stores structured data that doesn't fit the meta tables, you can create a table on activation using dbDelta(), which also handles future schema changes:
function sajjad_create_log_table() {
global $wpdb;
$table_name = $wpdb->prefix . 'sajjad_activity_log';
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE $table_name (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
action varchar(100) NOT NULL DEFAULT '',
created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
KEY user_id (user_id)
) $charset_collate;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
}
register_activation_hook( __FILE__, 'sajjad_create_log_table' );
dbDelta() is picky about formatting: put each field on its own line, use two spaces after PRIMARY KEY, and use KEY rather than INDEX. Place this code in your main plugin file so __FILE__ points to it.
Common Database Tasks and Where the Data Lives
Knowing the structure makes everyday troubleshooting much easier:
- Locked out after changing the site URL: Fix
siteurlandhomeinwp_options, or defineWP_HOMEandWP_SITEURLinwp-config.php. - Resetting a password without email: Use
wp user update 1 --user_pass="new-password"with WP-CLI, which hashes the password correctly. - Finding which plugin created a setting: Search
wp_optionsfor the option name, which usually includes the plugin's prefix. - Too many revisions: Count rows in
wp_postswherepost_type = 'revision'. - Moving a site to a new domain: Use
wp search-replacerather than raw SQL, because it correctly handles serialized data in postmeta and options.
That last point is important. Serialized PHP arrays store the length of each string. If you run a plain SQL REPLACE() that changes a URL's length, the serialized data breaks and settings silently disappear. WP-CLI's search-replace command and plugins like Better Search Replace handle this correctly.
wp search-replace 'https://old-domain.com' 'https://new-domain.com' --all-tables --dry-run
Remove --dry-run once you've checked the output.
FAQ: WordPress Database Structure
A standard single-site WordPress install creates 12 core tables. Multisite networks add several network tables plus a set of content tables for each site, and plugins often add their own.
Posts, pages, attachments, revisions, menu items, templates, and custom post types are all stored in the wp_posts table, with extra data stored in wp_postmeta.
No. Image files are stored in the wp-content/uploads folder on your server. The database only stores an attachment record in wp_posts and metadata such as file paths and image sizes in wp_postmeta.
Yes, but it requires renaming every table, updating $table_prefix in wp-config.php, and updating prefixed keys in wp_options and wp_usermeta. Back up first, and note it offers only a small security benefit.
It's safe only if you take a backup first and understand what you're changing. For most tasks, WordPress functions or WP-CLI commands are safer because they handle serialized data, caching, and related rows correctly.
Usually because plugins store large settings, logs, or expired transients there, and some plugins leave data behind after being deleted. Check the autoloaded data size in Site Health and remove options from plugins you no longer use.
Officially, WordPress requires MySQL or MariaDB. There is an official SQLite Database Integration plugin used by tools like WordPress Playground, but MySQL or MariaDB remains the standard for production sites.
Conclusion
The WordPress database structure is simpler than it first appears. Content lives in wp_posts, people live in wp_users, conversations live in wp_comments, settings live in wp_options, and four taxonomy tables handle categories and tags. Around each main table sits a flexible meta table that lets themes and plugins attach almost any data they need without changing the schema.
Once you understand these tables and how they connect, you'll troubleshoot faster, write safer custom code with WP_Query and $wpdb->prepare(), and spot plugins that are cluttering your database. Just remember the golden rule: always back up before you touch the database directly, and prefer WordPress APIs or WP-CLI over raw SQL whenever you can.


