Posts, meta and the WordPress database

Learn the core tables, how posts, meta, terms and options connect, and the functions that read and write them.

  • Intermediate
  • 9 min read
  • Updated 17 Oct 2026

What you will learn

  • Map content to core tables
  • Read and write post meta
  • Register meta so it is typed, sanitised and available in the REST API
  • Explain why direct SQL is rarely needed

Before you start

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:

TableHolds
wp_postsPosts, pages, attachments, revisions, menu items, blocks and every custom post type
wp_postmetaExtra fields for posts, as key and value pairs
wp_termsTerm names and slugs, such as a category called "News"
wp_term_taxonomyWhich taxonomy each term belongs to, its description, parent and post count
wp_term_relationshipsWhich posts have which terms
wp_termmetaExtra fields for terms
wp_usersUser accounts: login, hashed password, email
wp_usermetaExtra fields for users, including their roles
wp_commentsComments, also used for some other note-like data
wp_commentmetaExtra fields for comments
wp_optionsSite-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_typeWhat it is
postA blog post
pageA page
attachmentAn uploaded file
revisionA saved revision of another post
nav_menu_itemAn item in a navigation menu
wp_template, wp_blockTemplates and synced patterns from the Site Editor
myplugin_eventYour 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_idpost_idmeta_keymeta_value
50142myplugin_event_date2026-11-03
50242myplugin_price25

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:

PHP
// 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:

PHP
// 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:

PHP
// 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_terms stores the term name and slug.
  • wp_term_taxonomy links that term to a taxonomy such as category, and holds the parent and count.
  • wp_term_relationships links posts to terms.

You never join these by hand. Use the API:

PHP
// 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.

PHP
$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() and get_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 raw INSERT gets 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 by post_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() and delete_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.

Further reading