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.
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:
| Table | Stores |
|---|---|
wp_posts | Every 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_postmeta | Key/value custom field data attached to a row in wp_posts — one row per meta key per post, linked by post_id |
wp_options | Site-wide settings — one row per named option (site title, active theme, plugin settings), read with get_option() |
wp_users | Registered user accounts — username, hashed password, email, registration date |
wp_usermeta | Key/value custom data attached to a user, the same pattern as wp_postmeta but linked by user_id |
wp_terms | The actual term names (e.g. "Fiction," "Uncategorized") used across all taxonomies |
wp_term_taxonomy | Which taxonomy (category, tag, or a custom one like "genre") each term in wp_terms belongs to |
wp_term_relationships | The 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 |
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:
| Method | Returns |
|---|---|
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);
%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:
| Function | Reads/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');
$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.