WordPress stores a news site, an online shop and a membership directory in the same dozen tables. It manages this with a simple, flexible design: one table for every kind of content, and key and value tables for everything extra. Knowing these tables tells you where your data lives and which function to use to reach it.
The core tables
A single-site install creates these tables. Each name starts with the table prefix from wp-config.php, wp_ by default:
| Table | Holds |
|---|---|
wp_posts | Posts, pages, attachments, revisions, menu items, blocks and every custom post type |
wp_postmeta | Extra fields for posts, as key and value pairs |
wp_terms | Term names and slugs, such as a category called "News" |
wp_term_taxonomy | Which taxonomy each term belongs to, its description, parent and post count |
wp_term_relationships | Which posts have which terms |
wp_termmeta | Extra fields for terms |
wp_users | User accounts: login, hashed password, email |
wp_usermeta | Extra fields for users, including their roles |
wp_comments | Comments, also used for some other note-like data |
wp_commentmeta | Extra fields for comments |
wp_options | Site-wide settings and cached values |
Multisite adds network tables and gives each site its own set of content tables. Some older installs also have a wp_links table for the retired link manager.
Everything is a post
wp_posts is the centre of WordPress. The post_type column decides what each row is:
post_type | What it is |
|---|---|
post | A blog post |
page | A page |
attachment | An uploaded file |
revision | A saved revision of another post |
nav_menu_item | An item in a navigation menu |
wp_template, wp_block | Templates and synced patterns from the Site Editor |
myplugin_event | Your own custom post type, built later in this course |
Other important columns are ID, post_title, post_content, post_excerpt, post_status (such as publish, draft or private), post_name (the slug), post_author, post_date and post_parent.
Because every content type shares this table, the same functions work for all of them: get_post(), wp_insert_post(), wp_update_post() and WP_Query.
Meta: key and value tables
Custom data that does not fit a column goes in a meta table. In the course plugin, each event has a date and a price, stored as rows in wp_postmeta:
| meta_id | post_id | meta_key | meta_value |
|---|---|---|---|
| 501 | 42 | myplugin_event_date | 2026-11-03 |
| 502 | 42 | myplugin_price | 25 |
This design lets any plugin add any field without changing the schema. The trade-off is that meta_value is a long text column, so filtering or sorting by it is slower than a real indexed column. You will meet this again in the lesson on meta queries.
The date is stored as Y-m-d on purpose. Text in that format sorts in date order, which keeps later queries simple.
Reading and writing post meta
WordPress gives you four functions:
// Read one value. The third argument true means "return a single value, not an array".
$date = get_post_meta( $post_id, 'myplugin_event_date', true );
// Create or replace a value.
update_post_meta( $post_id, 'myplugin_price', 25 );
// Add a value, allowing several rows with the same key.
add_post_meta( $post_id, 'myplugin_tag_line', 'Bring your laptop' );
// Delete a value.
delete_post_meta( $post_id, 'myplugin_price' );
Points to remember:
- With
true,get_post_meta()returns an empty string when the key does not exist. Without it, you get an array of all values for that key. - Values come back as strings, because the column is text. Cast them:
(float) get_post_meta( $post_id, 'myplugin_price', true ). - If you save an array or object, WordPress serialises it to a string and unserialises it on the way out. This works, but serialised values cannot be searched efficiently. Prefer separate keys for data you will query.
- Meta keys that start with an underscore, such as
_myplugin_internal, are treated as protected and hidden from the classic Custom Fields panel.
The same pattern exists for other objects: get_user_meta(), get_term_meta() and get_comment_meta().
A helper that writes meta safely
Writing data is a state change, so the code must check that the current user is allowed to do it. Later lessons add nonces for forms. A small helper in your plugin:
// wp-content/plugins/myplugin/includes/events.php
function myplugin_set_price( int $post_id, float $price ): bool {
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return false;
}
$price = round( max( 0, $price ), 2 );
return (bool) update_post_meta( $post_id, 'myplugin_price', $price );
}
current_user_can( 'edit_post', $post_id ) checks whether the user may edit that specific post. Negative prices become 0 and the value is rounded to two decimal places before it is stored. Note that update_post_meta() returns false when the new value equals the stored one, so a false here does not always mean failure.
Registering meta
You can use meta keys without declaring them, but registering them is better. register_post_meta() tells WordPress the type, a default, how to sanitise the value and who may change it. It also makes the field available to the block editor and the REST API:
// wp-content/plugins/myplugin/includes/events.php
add_action( 'init', 'myplugin_register_event_meta' );
function myplugin_register_event_meta() {
$auth = static function ( $allowed, $meta_key, $post_id ) {
return current_user_can( 'edit_post', $post_id );
};
register_post_meta(
'myplugin_event',
'myplugin_event_date',
array(
'type' => 'string',
'description' => 'Event date in Y-m-d format.',
'single' => true,
'default' => '',
'sanitize_callback' => 'myplugin_sanitize_event_date',
'auth_callback' => $auth,
'show_in_rest' => true,
)
);
register_post_meta(
'myplugin_event',
'myplugin_price',
array(
'type' => 'number',
'description' => 'Ticket price. 0 means free.',
'single' => true,
'default' => 0,
'sanitize_callback' => static function ( $value ) {
return round( max( 0, (float) $value ), 2 );
},
'auth_callback' => $auth,
'show_in_rest' => true,
)
);
}
function myplugin_sanitize_event_date( $value ) {
$value = sanitize_text_field( (string) $value );
$date = DateTimeImmutable::createFromFormat( '!Y-m-d', $value );
return ( $date && $date->format( 'Y-m-d' ) === $value ) ? $value : '';
}
Now get_post_meta() returns the default for events without a value, both fields are cleaned whenever they are saved through the meta functions, and the REST API exposes them under meta. The date sanitiser only accepts a real date in Y-m-d format, so 2026-02-30 or 03/11/2026 is stored as an empty string.
This code registers meta for the myplugin_event post type, which you will create in the custom post types lesson. Registering meta before the post type exists is harmless. The fields appear in the editor and the REST API once the post type is registered with show_in_rest and custom-fields support.
Terms and taxonomies
Categories and tags are taxonomies, and each category or tag is a term. Three tables work together:
wp_termsstores the term name and slug.wp_term_taxonomylinks that term to a taxonomy such ascategory, and holds the parent and count.wp_term_relationshipslinks posts to terms.
You never join these by hand. Use the API:
// Terms attached to a post.
$genres = get_the_terms( $post_id, 'category' );
// Attach terms, replacing existing ones.
wp_set_object_terms( $post_id, array( 'news', 'community' ), 'category' );
The custom taxonomies lesson builds your own taxonomy.
Options
wp_options is the site's settings store: the site title, the active theme, plugin settings and cached values. Each row has a name, a value and an autoload flag. Options marked to autoload are loaded in one query on every request, which is fast for small settings and slow when large values pile up.
$site_name = get_option( 'blogname' );
update_option( 'myplugin_settings', array( 'per_page' => 12 ) );
The options and transients lesson covers this table, autoloading and caching in detail.
Why direct SQL is rarely needed
You can query these tables directly through the global $wpdb object, but the API functions are almost always better:
- Caching.
get_post(),get_post_meta()andget_option()use the object cache, so repeated reads cost nothing. A raw query skips it. - Hooks. API functions fire actions and filters other plugins rely on, such as
save_post. Raw SQL updates silently. - Consistency.
wp_insert_post()sets slugs, dates, GUIDs and counts. A rawINSERTgets these wrong. - Portability. The API respects the table prefix and Multisite.
When you load many posts with WP_Query, WordPress fetches all their meta in one query and caches it. Calling get_post_meta() in the Loop then reads from memory, not the database.
Direct SQL has a place for reports and bulk operations the API cannot express. The $wpdb lesson shows how to do it safely with prepared queries.
Summary
- Every content type is a row in
wp_posts, distinguished bypost_type. - Extra data lives in key and value meta tables: post, user, term and comment meta.
- Use
get_post_meta(),update_post_meta(),add_post_meta()anddelete_post_meta(), and cast values. - Register meta with
register_post_meta()for types, defaults, sanitisation, permissions and REST access. - Terms use three related tables; options store site-wide settings.
- Use the API rather than raw SQL for caching, hooks and consistency.
In the next lesson you will control hook order, pass arguments and add hooks to your own code.