Type something to search...
What is the WordPress database structure?

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.

TableWhat It Stores
wp_postsPosts, pages, attachments, revisions, menu items, custom post types, templates
wp_postmetaExtra key-value data attached to posts
wp_usersUser accounts and login details
wp_usermetaExtra key-value data attached to users, including roles
wp_commentsComments, pingbacks, and trackbacks
wp_commentmetaExtra key-value data attached to comments
wp_termsIndividual categories, tags, and custom taxonomy terms
wp_term_taxonomyWhich taxonomy each term belongs to, plus descriptions and counts
wp_term_relationshipsWhich posts are assigned to which terms
wp_termmetaExtra key-value data attached to terms
wp_optionsSite-wide settings, plugin settings, and transients
wp_linksThe 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:

  • post for regular blog posts
  • page for static pages
  • attachment for files in the Media Library
  • revision for saved revisions of posts and pages
  • nav_menu_item for individual items in your navigation menus
  • wp_template and wp_template_part for block theme templates edited in the Site Editor
  • wp_global_styles for customizations made in Appearance > Editor > Styles
  • wp_block for synced patterns (formerly reusable blocks)
  • wp_navigation for Navigation block menus
  • Any custom post type registered by your theme or plugins, such as product for 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, and inherit (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: 1 for approved, 0 for pending, spam for spam, and trash for trashed comments.
  • comment_parent: The ID of the parent comment for threaded replies.
  • user_id: The ID of a logged-in commenter, or 0 for guests.
  • comment_type: Usually comment, but also pingback, 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, and wp_woocommerce_sessions.
  • Action Scheduler (bundled with WooCommerce and many other plugins): wp_actionscheduler_actions and related tables for background jobs.
  • Yoast SEO: wp_yoast_indexable and related tables for its indexing system.
  • Wordfence: Several wp_wf* tables for firewall and scan data.
  • Redirection: wp_redirection_items and 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_author points to wp_users.ID.
  • wp_postmeta.post_id points to wp_posts.ID.
  • wp_posts.post_parent points to another row in wp_posts.
  • wp_comments.comment_post_ID points to wp_posts.ID, and wp_comments.user_id points to wp_users.ID.
  • wp_commentmeta.comment_id points to wp_comments.comment_ID.
  • wp_usermeta.user_id points to wp_users.ID.
  • wp_term_taxonomy.term_id points to wp_terms.term_id.
  • wp_term_relationships.term_taxonomy_id points to wp_term_taxonomy.term_taxonomy_id, and object_id points to a post (or link) ID.
  • wp_termmeta.term_id points to wp_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.

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

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

  3. Desktop clients: Tools like TablePlus, HeidiSQL, DBeaver, or MySQL Workbench can connect over SSH to your server's database.

  4. 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 siteurl and home in wp_options, or define WP_HOME and WP_SITEURL in wp-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_options for the option name, which usually includes the plugin's prefix.
  • Too many revisions: Count rows in wp_posts where post_type = 'revision'.
  • Moving a site to a new domain: Use wp search-replace rather 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.

Tags :
Share :

Related Posts

What are the best WordPress security plugins?

What are the best WordPress security plugins?

The best WordPress security plugins for most sites are Wordfence, Sucuri Security, Solid Security, MalCare, All-In-One Security (AIOS), Patchstack, a

Dive Deeper
What is the difference between posts and pages in WordPress?

What is the difference between posts and pages in WordPress?

The main difference between posts and pages in WordPress is that posts are timely, dated entries that appear in your blog feed, archives, and RSS fee

Dive Deeper
How much does it cost to build a WordPress website?

How much does it cost to build a WordPress website?

Building a WordPress website can cost anywhere from roughly the price of a domain and a year of budget hosting, if you do it yourself with free tools

Dive Deeper