WordPress Database Structure: Tables Explained


4 Feat

Every piece of a site, from a page to a plugin setting to a comment, ends up in one of the WordPress database tables, and knowing which table holds what is the difference between a developer who guesses and one who can debug. WordPress uses a deliberately small schema of twelve core tables in MySQL or MariaDB, with a few more on multisite. This guide explains each table, how they relate, how to query them safely with $wpdb, when to add your own, and which habits keep them fast.

The twelve core WordPress database tables

All table names carry the prefix set in wp-config.php, wp_ by default. The prefix is why you should never hard-code a name: $wpdb->posts and $wpdb->prefix . 'my_table' resolve correctly on any install, including a multisite subsite whose prefix is wp_7_.

TableHoldsKey columns
wp_postsPosts, pages, attachments, revisions, menu items, templates, every custom post typeID, post_type, post_status, post_name
wp_postmetaKey/value pairs attached to a postpost_id, meta_key, meta_value
wp_termsTerm names and slugsterm_id, name, slug
wp_term_taxonomyWhich taxonomy a term belongs to, plus parent and countterm_taxonomy_id, taxonomy, parent
wp_term_relationshipsLinks objects to termsobject_id, term_taxonomy_id
wp_termmetaKey/value pairs attached to a termterm_id, meta_key
wp_usersLogin, hashed password, email, display nameID, user_login, user_email
wp_usermetaRoles, capabilities, profile fields, per-user settingsuser_id, meta_key
wp_commentsComments, pingbacks, review data from pluginscomment_ID, comment_post_ID, comment_approved
wp_commentmetaKey/value pairs attached to a commentcomment_id, meta_key
wp_optionsSite settings, plugin options, transients, cron schedule, rewrite rulesoption_name, option_value, autoload
wp_linksLegacy blogroll, unused todaylink_id
Diagram of the core WordPress database tables: posts, postmeta, users, usermeta, options, comments and the three taxonomy tables
Core WordPress database tables

Notice the pattern: four “entity” tables (posts, terms, users, comments) each paired with a meta table. That entity-plus-meta design is the single most important idea in the WordPress database tables, and it explains both the flexibility and the performance characteristics of everything built on top.

How wp_posts and wp_postmeta hold almost everything

wp_posts is the busiest table on any site. The post_type column decides what a row is: post, page, attachment for media, revision for history, nav_menu_item for menus, and wp_template, wp_template_part, wp_navigation and wp_global_styles for block themes. Anything you register with register_post_type() lands here as well, which is why our custom post types guide spends so little time on storage.

post_status (publish, draft, future, private, trash, inherit, auto-draft) and post_parent do a lot of quiet work. Revisions and attachments are children of the post they belong to, with a status of inherit.

wp_postmeta is an entity-attribute-value store: one row per key per post, with meta_value as longtext. It is indexed on post_id and on the first 191 characters of meta_key, but not on meta_value. Filtering or sorting a large site by a meta value is therefore a full scan, and that single fact should shape how you model data. Anything you need to query by belongs in a taxonomy, a dedicated column, or your own table, not in meta.

Taxonomy tables: terms, term_taxonomy and relationships

Categories, tags and custom taxonomies use three tables together. wp_terms stores the name and slug once. wp_term_taxonomy says which taxonomy that term belongs to, its parent for hierarchical taxonomies, and a cached count. wp_term_relationships is the join table linking an object ID, usually a post, to a term_taxonomy_id.

Because the join runs on integer keys and term_taxonomy_id is indexed, taxonomy queries are fast even with hundreds of thousands of posts. That is the practical reason to prefer a taxonomy over post meta for anything you filter by, such as a team, a league, a season or a product attribute. A taxonomy filter touches three small tables; a meta filter scans one large one.

Querying WordPress database tables safely with $wpdb

The wpdb class is the only sanctioned way to run SQL. Use WP_Query, get_posts(), get_terms() and the options API whenever they can express what you need, because they are cached and respect hooks. Drop to $wpdb for aggregates, reports and joins the high-level APIs cannot build. Every variable goes through $wpdb->prepare() with a typed placeholder, and every table name comes from a $wpdb property.

<?php
global $wpdb;

$post_type = 'sf_player';
$team_slug = 'chicago';

$sql = $wpdb->prepare(
    "SELECT p.ID, p.post_title, pm.meta_value AS jersey
       FROM {$wpdb->posts} p
       INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
       INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
       INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
       LEFT JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = %s
      WHERE p.post_type = %s
        AND p.post_status = 'publish'
        AND tt.taxonomy = %s
        AND t.slug = %s
   ORDER BY CAST( pm.meta_value AS UNSIGNED ) ASC
      LIMIT %d",
    'sf_jersey_number',
    $post_type,
    'sf_team',
    $team_slug,
    50
);

$players = $wpdb->get_results( $sql );

foreach ( $players as $player ) {
    echo esc_html( $player->post_title ) . ' #' . (int) $player->jersey . '<br>';
}

%s, %d and %f are the placeholders; %i for identifiers arrived in WordPress 6.2 for the rare case where a column name is dynamic. For writes, the helper methods add quoting and formatting for you and are much harder to get wrong than hand-built statements.

global $wpdb;

// INSERT with explicit formats: %d for integers, %s for strings.
$wpdb->insert(
    $wpdb->prefix . 'sf_game_events',
    array(
        'game_id'    => 4412,
        'minute'     => 67,
        'event_type' => 'goal',
        'player_id'  => 918,
    ),
    array( '%d', '%d', '%s', '%d' )
);
$event_id = $wpdb->insert_id;

// UPDATE with a WHERE clause and matching formats.
$wpdb->update(
    $wpdb->prefix . 'sf_game_events',
    array( 'minute' => 68 ),
    array( 'id' => $event_id ),
    array( '%d' ),
    array( '%d' )
);
Prepared SQL query against wp_posts, showing how to read WordPress database tables safely with $wpdb->prepare()” class=”wp-image-10680″/><figcaption class=SQL query example for wp_posts

Multisite prefixes and custom tables

On a multisite network the main site keeps the plain prefix and every other site gets a numbered one, so site 7 stores content in wp_7_posts and wp_7_options. Users and their meta are shared network-wide in wp_users and wp_usermeta, and the network itself adds wp_blogs, wp_blogmeta, wp_site, wp_sitemeta, wp_signups and wp_registration_log. $wpdb->prefix switches with the current site while $wpdb->base_prefix stays fixed, which matters as soon as you call switch_to_blog(). The multisite setup guide covers the network side.

Custom tables are justified when you have high-volume rows with a fixed shape that you need to query by several columns: play-by-play events, analytics hits, an import queue. Create them with dbDelta() on activation, store a schema version in an option, and let dbDelta() handle upgrades by re-running with the new definition. It is picky about formatting: two spaces after PRIMARY KEY, one column per line, and the KEY name before its column list.

register_activation_hook( __FILE__, 'sf_events_install' );

function sf_events_install() {
    global $wpdb;
    $table   = $wpdb->prefix . 'sf_game_events';
    $charset = $wpdb->get_charset_collate();

    $sql = "CREATE TABLE {$table} (
        id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
        game_id bigint(20) unsigned NOT NULL,
        player_id bigint(20) unsigned NOT NULL,
        minute smallint(5) unsigned NOT NULL DEFAULT 0,
        event_type varchar(32) NOT NULL,
        created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
        PRIMARY KEY  (id),
        KEY game_id (game_id),
        KEY player_event (player_id, event_type)
    ) {$charset};";

    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    dbDelta( $sql );
    update_option( 'sf_events_db_version', '1.0.0', false );
}

The StepFox sports plugins keep teams, players and games as custom post types with taxonomies for league and season, because editors need to open them in the block editor and the StepFox themes need to render them with Query Loop blocks.

Keeping the tables fast

wp_options deserves special attention. Every row with autoload set to yes (or on since 6.6) is loaded into memory on every request, so a plugin that autoloads a 2 MB import log slows every page on the site. Pass false as the fourth argument to add_option() for anything large or rarely read, and audit autoloaded size occasionally with SELECT SUM(LENGTH(option_value)) FROM wp_options WHERE autoload IN ('yes','on').

Expired transients also pile up in wp_options when no persistent object cache is installed; WordPress cleans them only opportunistically. Revisions grow wp_posts without limit unless WP_POST_REVISIONS is set. And orphaned wp_postmeta rows survive when plugins delete posts with raw SQL instead of wp_delete_post(). A quarterly cleanup, a persistent object cache, and the habits in our performance guide keep the WordPress database tables lean without heroics.

Key takeaways

  • The WordPress database tables follow an entity-plus-meta pattern: posts, terms, users and comments, each with a key/value companion table.
  • wp_posts holds every post type including block templates; post_type and post_status decide what a row is.
  • meta_value is not indexed, so filter by taxonomies or dedicated columns and keep meta for display data.
  • Always use $wpdb->prepare() with typed placeholders and $wpdb table properties; prefer the high-level APIs when they fit.
  • Create custom tables with dbDelta() only for high-volume, fixed-shape data, and version the schema in an option.
  • Watch autoloaded options, expired transients and revisions; they are the usual reasons a site’s database gets slow.

stephog Avatar

Share Article

Need a Custom Theme?

We create unique, high-performance WordPress themes tailored to your brand.

ABOUT US

© STEPFOX STUDIO 2020