The WordPress Database

Behind every post, page, comment, setting, and user account, WordPress stores its data in an ordinary MySQL database. There's no magic here — it's plain relational tables that you could inspect with phpMyAdmin or any MySQL client. Understanding this schema, even at a high level, makes debugging, custom queries, and performance work far easier — and it demystifies a lot of "WordPress magic" once you see it's really just rows in tables.

Table prefix: By default table names start with wp_, but this prefix is configurable during installation (see $table_prefix in wp-config.php) — a security-through-obscurity measure some hosts recommend changing from the default.

The Core Table Schema

WordPress ships with roughly a dozen core tables. The ones you'll interact with constantly are:

TableStores
wp_postsEvery post, page, attachment, revision, AND every custom post type row — all in one table, distinguished by a post_type column (e.g. 'post', 'page', 'book')
wp_postmetaKey/value custom field data attached to a row in wp_posts — one row per meta key per post, linked by post_id
wp_optionsSite-wide settings — one row per named option (site title, active theme, plugin settings), read with get_option()
wp_usersRegistered user accounts — username, hashed password, email, registration date
wp_usermetaKey/value custom data attached to a user, the same pattern as wp_postmeta but linked by user_id
wp_termsThe actual term names (e.g. "Fiction," "Uncategorized") used across all taxonomies
wp_term_taxonomyWhich taxonomy (category, tag, or a custom one like "genre") each term in wp_terms belongs to
wp_term_relationshipsThe many-to-many link connecting posts in wp_posts to terms in wp_term_taxonomy — this is literally how a post "has" a category or tag
Key insight: Categories, tags, and any custom taxonomy you register (like the "Genre" taxonomy from the previous lesson) are all powered by the same three wp_terms / wp_term_taxonomy / wp_term_relationships tables — WordPress just tags each row with which taxonomy it belongs to.

The Global $wpdb Object

WordPress exposes its database connection through a global object called $wpdb. It wraps the raw MySQL connection and gives you convenience methods for running queries, so you rarely need to open a raw mysqli or PDO connection yourself inside WordPress code:

<?php
global $wpdb;
$results = $wpdb->get_results("SELECT * FROM {$wpdb->posts} WHERE post_status = 'publish'");

Note $wpdb->posts instead of hardcoding wp_posts — this automatically respects whatever table prefix the site is actually using.

Other Useful $wpdb Methods

Beyond get_results() for multi-row results, $wpdb exposes several more targeted methods depending on what shape of data you expect back:

MethodReturns
get_results()An array of row objects — use for multi-row SELECT queries
get_row()A single row object — use when you expect exactly one match
get_var()A single scalar value — use for things like COUNT(*)
insert() / update() / delete()Convenience wrappers for writing data that build the SQL and escape values for you, without needing prepare() manually
<?php
global $wpdb;
$total = $wpdb->get_var(
    "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'book'"
);

$wpdb->insert(
    $wpdb->prefix . 'mfp_logs',
    ['message' => 'Book viewed', 'created_at' => current_time('mysql')]
);

Writing Safe Queries with prepare()

Never build a SQL query by concatenating raw strings with user input — that's the classic SQL injection vulnerability, letting an attacker smuggle their own SQL through an input field. $wpdb->prepare() solves this using placeholders, the same way prepared statements work in Core PHP with PDO or mysqli:

<?php
global $wpdb;

// UNSAFE: never do this — $genre could contain injected SQL
// $sql = "SELECT * FROM {$wpdb->posts} WHERE post_title = '" . $genre . "'";

// SAFE: placeholders are escaped automatically by prepare()
$sql = $wpdb->prepare(
    "SELECT * FROM {$wpdb->posts} WHERE post_title = %s AND post_type = %s",
    $genre,
    'book'
);
$results = $wpdb->get_results($sql);
Why this matters: %s (string), %d (integer), and %f (float) are placeholder tokens. prepare() escapes and quotes each value before substituting it into the query, so user-supplied data can never break out of its slot and alter the query's structure — exactly the same protection PDO's bindParam() or mysqli's prepared statements give you in plain Core PHP.

Prefer the Helper Functions for Common Cases

For the vast majority of everyday reads and writes, you don't need raw SQL or $wpdb at all — WordPress provides simple helper functions that handle the query (and caching) for you:

FunctionReads/writes
get_option($name)Reads a single row from wp_options
update_option($name, $value)Creates or updates a row in wp_options
get_post_meta($post_id, $key, true)Reads a single custom field value from wp_postmeta
update_post_meta($post_id, $key, $value)Creates or updates a custom field value in wp_postmeta
<?php
// Reading and writing a site-wide option
$books_per_page = get_option('books_per_page', 10);
update_option('books_per_page', 20);

// Reading and writing a post's custom field
$isbn = get_post_meta($post_id, 'book_isbn', true);
update_post_meta($post_id, 'book_isbn', '978-3-16-148410-0');
Rule of thumb: Reach for $wpdb->prepare() and raw SQL only when you truly need custom joins or aggregate queries the helper functions can't express — for ordinary options and meta reads/writes, the helper functions above are simpler, safer by default, and sometimes benefit from WordPress's internal object caching.
With the schema and data-access patterns clear, the next step is turning this knowledge into a real extension of WordPress itself — continue to Plugin Development Basics.