Skip to main content
WP HealthKit

WordPress Database Optimization: MySQL Index Strategies

June 17, 202618 min readPerformanceBy Jamie

Table of Contents

Introduction

WordPress database index optimization is one of the highest-impact performance improvements available to plugin developers. A missing index can slow queries from milliseconds to seconds. An inefficient index structure can force MySQL to scan thousands of rows when a properly designed index would return results instantly. Understanding WordPress database index optimization query performance directly impacts site speed, scalability, and user experience.

Many WordPress plugins create database tables during activation but skip index design. They rely on primary keys and hope WordPress's native tables have appropriate indexes. This approach creates bottlenecks. When your plugin stores meta data on custom tables without indexes, or relies on wp_postmeta queries without proper indexing, database performance degrades.

WP HealthKit's performance audits identify missing or inefficient indexes in plugin code. Our system analyzes custom database tables and WordPress metadata queries to recommend index strategies that would eliminate query bottlenecks. This guide teaches you how to implement WordPress database index optimization that ensures your plugin remains performant as data volume grows.

Database indexing is one of the most misunderstood concepts in WordPress development. Developers often treat indexes as optional performance optimizations, something to add if the site gets slow. In reality, indexes are fundamental to query performance. A table without appropriate indexes forces MySQL to perform full table scans—reading and comparing every single row to find matches. With millions of rows this becomes catastrophically slow. The same query with appropriate indexes returns results instantly by using the index to navigate directly to matching rows.

WordPress database optimization through proper indexing is especially important because WordPress's default schema includes generic tables like wp_postmeta and wp_usermeta that can accumulate millions of rows. These tables have some default indexes, but plugin code often queries them in ways the default indexes don't support efficiently. Understanding how to add strategic indexes to support your plugin's query patterns is essential for maintaining performance as data grows.

Understanding MySQL Index Types

MySQL supports multiple index types. Each serves different purposes and has different performance characteristics.

PRIMARY KEY Indexes

Every table should have exactly one primary key. It uniquely identifies each row.

CREATE TABLE wp_my_plugin_data (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    user_id BIGINT UNSIGNED NOT NULL,
    created_at DATETIME NOT NULL,
    PRIMARY KEY (id),
    KEY user_idx (user_id),
    KEY created_idx (created_at)
) ENGINE=InnoDB;

The primary key automatically:

  • Prevents duplicate rows
  • Provides the clustered index (MySQL sorts all data by primary key)
  • Enables fast lookups by ID

Single-Column Indexes

Simple indexes on individual columns speed up WHERE clauses using that column.

-- Bad query without index on user_id
SELECT * FROM wp_my_plugin_data WHERE user_id = 123;
-- MySQL scans entire table: slow with millions of rows

CREATE INDEX idx_user_id ON wp_my_plugin_data(user_id);

-- Same query with index: fast
-- MySQL uses index to find matching rows immediately
SELECT * FROM wp_my_plugin_data WHERE user_id = 123;

Index overhead:

  • Storage: Indexes consume disk space (typically 5-10% of table size)
  • Write cost: INSERT, UPDATE, DELETE operations must update indexes
  • Maintenance: Indexes fragment over time and need optimization

Create indexes strategically on frequently filtered columns.

UNIQUE Indexes

Unique indexes prevent duplicate values in a column or column combination.

CREATE TABLE wp_my_plugin_user_settings (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    user_id BIGINT UNSIGNED NOT NULL,
    setting_name VARCHAR(255) NOT NULL,
    setting_value TEXT,
    PRIMARY KEY (id),
    UNIQUE KEY unique_user_setting (user_id, setting_name)
) ENGINE=InnoDB;

A UNIQUE index on (user_id, setting_name) ensures each user has only one value per setting. Attempts to insert duplicates fail.

// This will fail if user 123 already has a 'theme' setting
wp_die( wpdb->insert( 'wp_my_plugin_user_settings', array(
    'user_id'      => 123,
    'setting_name' => 'theme',
    'setting_value' => 'dark_mode',
)));

FULLTEXT Indexes

FULLTEXT indexes enable natural language searching on text columns.

CREATE TABLE wp_my_plugin_documents (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    title VARCHAR(500) NOT NULL,
    content LONGTEXT NOT NULL,
    PRIMARY KEY (id),
    FULLTEXT INDEX ft_search (title, content)
) ENGINE=InnoDB;

Query using FULLTEXT:

$results = $wpdb->get_results( $wpdb->prepare(
    "SELECT * FROM wp_my_plugin_documents 
     WHERE MATCH(title, content) AGAINST(%s IN BOOLEAN MODE)",
    $search_term
));

FULLTEXT indexes enable phrase matching, boolean operators, and relevance ranking—powerful for plugin search functionality.

SPATIAL Indexes

For geographic data, SPATIAL indexes accelerate location-based queries (rarely needed in typical WordPress plugins).

Composite Index Design Principles

Composite (multi-column) indexes are more powerful than single-column indexes. They accelerate complex queries with multiple WHERE conditions.

The Three-Column Rule

When designing composite indexes, follow the "three-column rule": Equality, Range, Sort (ERS).

  1. Equality columns: Columns that filter by exact value
  2. Range columns: Columns that filter by range (>, <, BETWEEN)
  3. Sort columns: Columns used in ORDER BY

Index columns in this order for optimal performance.

Example:

-- Query: Find user's posts from the last 30 days, sorted by date
SELECT * FROM posts 
WHERE user_id = 123 
  AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) 
ORDER BY created_at DESC;

-- Good index following ERS rule:
-- E: user_id (equality)
-- R: created_at (range)
-- S: created_at (sort) - already in range column
CREATE INDEX idx_user_created ON posts(user_id, created_at);

This single index accelerates the query efficiently:

// Create index
$wpdb->query( 
    "CREATE INDEX idx_user_created ON {$wpdb->posts}
     (post_author, post_date)"
);

// Query now uses the index efficiently
$results = $wpdb->get_results( $wpdb->prepare(
    "SELECT * FROM {$wpdb->posts}
     WHERE post_author = %d
       AND post_date >= %s
     ORDER BY post_date DESC
     LIMIT 20",
    $user_id,
    $date_cutoff
));

Column Order Matters

Index column order dramatically affects query performance. MySQL uses indexes left-to-right.

-- Index: (user_id, created_at, status)

-- Query 1: Uses index fully
SELECT * FROM posts WHERE user_id = 123 AND created_at > '2026-01-01';
-- Uses both user_id and created_at from index

-- Query 2: Uses only first column
SELECT * FROM posts WHERE created_at > '2026-01-01';
-- Skips user_id, only uses index for created_at? NO!
-- Cannot use the index—created_at is not first column

-- Query 3: Uses first column, but range prevents further use
SELECT * FROM posts WHERE user_id > 100 AND created_at > '2026-01-01';
-- Uses index for user_id (range), but cannot use created_at
-- (range condition prevents using later columns)

The second query doesn't use the index at all because created_at isn't the first column. Create separate single-column indexes for queries that don't use the first index column.

Selective Indexes for Partial Data

Indexes on partial data reduce index size and improve INSERT/UPDATE performance.

-- Standard index
CREATE INDEX idx_published_posts ON posts(post_status, post_date);

-- Better: Only index published posts (if status rarely changes)
CREATE INDEX idx_published_posts ON posts(post_date) 
WHERE post_status = 'publish';

Partial indexes:

  • Consume less space
  • Update faster (fewer rows affected)
  • May require MySQL 5.7.8+ or MariaDB 10.1.1+

Identify Index Optimization Opportunities

Missing or inefficient indexes are silent killers of plugin performance. WP HealthKit analyzes your plugin's database queries and table structures to identify optimization opportunities: missing indexes, redundant indexes, and inefficiently ordered composite indexes.

Optimize your database: Upload your plugin to WP HealthKit for database index performance recommendations.

Using EXPLAIN to Analyze Queries

EXPLAIN is your window into how MySQL executes queries. It reveals whether queries use indexes and how efficiently.

Reading EXPLAIN Output

// Analyze a query
$results = $wpdb->get_results( 
    "EXPLAIN SELECT * FROM wp_users WHERE user_login = 'admin'"
);

/*
Output:
id | select_type | table   | type | possible_keys | key | key_len | ref | rows | Extra
1  | SIMPLE      | wp_users| ALL  | NULL          | NULL| NULL    | NULL| 5    | Using where
*/

Understanding each column:

  • type: How MySQL accesses rows

    • const: Fastest, single row by PRIMARY/UNIQUE key
    • eq_ref: Row from indexed column during join
    • ref: Multiple rows matching indexed column
    • range: Rows matching a range condition
    • index: Full index scan (slower)
    • ALL: Full table scan (slowest)
  • key: Which index was used (NULL = no index)

  • rows: Estimated rows examined

  • Extra: Additional operation info

Identifying Missing Indexes

function analyze_slow_query() {
    $results = $wpdb->get_results(
        "EXPLAIN SELECT * FROM wp_postmeta 
         WHERE meta_key = 'my_plugin_data' 
           AND post_id = 123"
    );
    
    foreach ( $results as $row ) {
        echo "Type: " . $row->type;     // ALL = full scan
        echo "Key: " . $row->key;        // NULL = no index used
        echo "Rows: " . $row->rows;      // Millions of rows scanned!
    }
}

This query scans the entire wp_postmeta table despite filtering by specific values. Creating an index would fix it:

$wpdb->query(
    "CREATE INDEX idx_meta_key_post 
     ON {$wpdb->postmeta}(meta_key, post_id)"
);

// Same query now uses the index
$results = $wpdb->get_results(
    "EXPLAIN SELECT * FROM wp_postmeta 
     WHERE meta_key = 'my_plugin_data' 
       AND post_id = 123"
);
/*
Now: type = ref, key = idx_meta_key_post, rows = 1 or 2
*/

Using EXPLAIN FORMAT=JSON

More detailed output with JSON format:

function analyze_query_detailed() {
    $result = $wpdb->get_results(
        "EXPLAIN FORMAT=JSON SELECT * FROM wp_posts 
         WHERE post_author = 123 
         AND post_date > '2026-01-01'",
        ARRAY_A
    );
    
    $json = json_decode( $result[0]['EXPLAIN'], true );
    
    // Check if query is optimized
    $filtered_rows = $json['query_block']['table']['filtered'];
    $examined_rows = $json['query_block']['table']['rows_examined'];
    
    // Good: Filtered = examined (index used)
    // Bad: Examined >> filtered (many false matches)
}

Indexing Custom Plugin Tables

Custom plugin tables need indexes designed specifically for your queries.

Creating Tables with Optimal Indexes

function create_plugin_tables() {
    global $wpdb;
    
    $charset_collate = $wpdb->get_charset_collate();
    
    $sql = "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}my_plugin_logs (
        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        user_id BIGINT UNSIGNED NOT NULL,
        event_type VARCHAR(50) NOT NULL,
        event_details JSON,
        ip_address VARCHAR(45),
        created_at DATETIME NOT NULL,
        
        PRIMARY KEY (id),
        
        -- Index for user activity queries
        KEY idx_user_created (user_id, created_at),
        
        -- Index for event type filtering
        KEY idx_event_type (event_type, created_at),
        
        -- Partial index for recent errors only
        KEY idx_recent_errors (created_at) 
            WHERE event_type = 'error',
        
        -- Index for IP-based analysis
        KEY idx_ip_address (ip_address)
    ) ENGINE=InnoDB {$charset_collate}";
    
    require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
    dbDelta( $sql );
}

Indexing During Plugin Activation

Add indexes during plugin activation, not on every page load:

function my_plugin_activate() {
    global $wpdb;
    
    // Create base table if not exists
    create_plugin_tables();
    
    // Add indexes if they don't exist
    $indexes = $wpdb->get_results(
        "SHOW INDEXES FROM {$wpdb->prefix}my_plugin_logs"
    );
    
    $index_names = wp_list_pluck( $indexes, 'Key_name' );
    
    // Add missing indexes
    if ( ! in_array( 'idx_user_event', $index_names ) ) {
        $wpdb->query(
            "ALTER TABLE {$wpdb->prefix}my_plugin_logs 
             ADD INDEX idx_user_event (user_id, event_type)"
        );
    }
}
register_activation_hook( __FILE__, 'my_plugin_activate' );

Detecting Slow Queries on Custom Tables

function log_slow_queries( $query, $seconds ) {
    // If query takes longer than 0.5 seconds, log it
    if ( $seconds > 0.5 ) {
        error_log( "Slow query (" . number_format( $seconds, 3 ) . "s): " . $query );
        
        // Analyze the slow query
        $explains = $wpdb->get_results( "EXPLAIN " . $query );
        foreach ( $explains as $explain ) {
            if ( $explain->type === 'ALL' || ! $explain->key ) {
                error_log( "  ^ Missing index: examined " . $explain->rows . " rows" );
            }
        }
    }
}
add_filter( 'query', 'log_slow_queries', 10, 2 );

Optimizing wp_postmeta Performance

wp_postmeta is WordPress's default storage for post metadata. Its performance is critical for plugins.

Understanding wp_postmeta Structure

SELECT * FROM wp_postmeta LIMIT 5;
/*
meta_id | post_id | meta_key      | meta_value
1       | 10      | _wp_page_template | default
2       | 10      | my_plugin_data    | {"key": "value"}
*/

By default, wp_postmeta has:

  • PRIMARY KEY (meta_id)
  • INDEX (post_id)
  • No index on meta_key!

The Missing Index Problem

// Very common query in plugins
$results = $wpdb->get_results( $wpdb->prepare(
    "SELECT * FROM {$wpdb->postmeta}
     WHERE meta_key = %s AND post_id = %d",
    'my_plugin_setting',
    123
));

Without an index on meta_key, MySQL:

  1. Scans the entire wp_postmeta table
  2. Checks meta_key value for each row
  3. Checks post_id value for matching rows

With millions of postmeta rows, this is extremely slow.

WordPress Post Meta Index Status

function check_postmeta_indexes() {
    global $wpdb;
    
    $indexes = $wpdb->get_results(
        "SHOW INDEXES FROM {$wpdb->postmeta}"
    );
    
    $has_meta_key_index = false;
    $has_composite_index = false;
    
    foreach ( $indexes as $index ) {
        if ( $index->Column_name === 'meta_key' ) {
            $has_meta_key_index = true;
        }
        if ( $index->Column_name === 'post_id' && 
             $index->Seq_in_index === 1 ) {
            // Check if next column is meta_key
            $next_column = array_filter( $indexes, function( $idx ) use ( $index ) {
                return $idx->Key_name === $index->Key_name 
                    && $idx->Seq_in_index === 2 
                    && $idx->Column_name === 'meta_key';
            });
            
            if ( ! empty( $next_column ) ) {
                $has_composite_index = true;
            }
        }
    }
    
    if ( ! $has_meta_key_index && ! $has_composite_index ) {
        error_log( "WARNING: wp_postmeta lacks proper indexing" );
        return false;
    }
    
    return true;
}

Optimizing postmeta Queries

For custom postmeta, WordPress 6.4+ adds an index on (post_id, meta_key), dramatically improving performance. For older WordPress versions, plugins can add indexes manually:

// For plugins supporting WP < 6.4, add index during activation
function add_postmeta_index() {
    global $wpdb;
    
    $indexes = $wpdb->get_results(
        "SHOW INDEXES FROM {$wpdb->postmeta} 
         WHERE Column_name = 'meta_key'"
    );
    
    if ( empty( $indexes ) ) {
        $wpdb->query(
            "ALTER TABLE {$wpdb->postmeta} 
             ADD INDEX idx_meta_key (meta_key(32))"
        );
    }
}

Efficient postmeta Query Patterns

// Bad: Meta query without filtering on post_type
$results = $wpdb->get_results(
    "SELECT p.ID, p.post_title FROM {$wpdb->posts} p
     INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
     WHERE pm.meta_key = 'my_plugin_data'"
);
// This joins ALL postmeta rows with ALL posts

// Better: Filter on post_type to reduce joined rows
$results = $wpdb->get_results(
    "SELECT p.ID, p.post_title FROM {$wpdb->posts} p
     INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
     WHERE p.post_type = 'post'
       AND pm.meta_key = 'my_plugin_data'"
);
// Much faster—post_type filter reduces table scans

Using get_post_meta instead of custom queries:

// Custom query: Fast but requires index verification
$meta_value = $wpdb->get_var( $wpdb->prepare(
    "SELECT meta_value FROM {$wpdb->postmeta}
     WHERE post_id = %d AND meta_key = %s",
    $post_id,
    'my_key'
));

// WordPress API: Slower without direct DB query
$meta_value = get_post_meta( $post_id, 'my_key', true );

// For reading single values, use get_post_meta (cleaner API)
// For reading many values at once, use direct query (faster)

Covering Indexes and Query Optimization

Covering indexes contain all columns needed to answer a query without accessing the main table.

Understanding Covering Indexes

-- Basic index
CREATE INDEX idx_user_status ON posts(post_author, post_status);

-- This query COVERS with the index
SELECT post_author, post_status FROM posts 
WHERE post_author = 123 AND post_status = 'publish';
-- The index contains author, status—no need to access the table!

-- This query does NOT cover
SELECT post_author, post_title FROM posts 
WHERE post_author = 123;
-- The index doesn't contain post_title—must access the main table

Creating Covering Indexes

// Query: Get user's published post titles
// SELECT post_author, post_title FROM posts 
// WHERE post_author = ? AND post_status = 'publish'
// ORDER BY post_date DESC

// Index must include author, status, date AND title
CREATE INDEX idx_user_published_titles ON posts
(post_author, post_status, post_date, post_title);

// Now the index covers the entire query—ultra-fast

When to Use Covering Indexes

Covering indexes are most valuable for:

  • Frequently run queries: Index maintenance cost is worth the speed gain
  • Large tables: Index overhead is negligible vs speed improvement
  • Read-heavy workloads: Write performance impact is minimal
  • High-volume queries: Small per-query gains accumulate

Don't create covering indexes for:

  • Rarely run queries
  • Queries already fast enough
  • Small tables (full scan is already fast)
  • Write-heavy workloads (index maintenance becomes expensive)

Broader Context and Best Practices

Performance optimization in WordPress plugins requires understanding the full request lifecycle, from the initial HTTP request through PHP execution, database queries, and response generation. Every millisecond added to this cycle multiplies across every page load for every visitor. A plugin that adds just 50 milliseconds of overhead might seem insignificant, but on a site serving 100,000 page views per day, that translates to nearly 1,400 hours of cumulative user waiting time per year. This perspective helps prioritize optimization efforts where they have the greatest impact on real user experience.

Database queries are the most common performance bottleneck in WordPress plugins, but not all query optimization strategies are equally effective. Adding an index speeds up read operations but slows down writes. Caching eliminates queries entirely but introduces cache invalidation complexity. Denormalization reduces JOIN operations but creates data consistency challenges. Understanding these trade-offs is essential for making informed optimization decisions rather than blindly applying generic advice. Profiling tools and query monitoring help identify which specific queries deserve optimization attention and which optimization strategy best fits each situation.

Core Web Vitals have fundamentally changed how performance is measured and valued. Google's inclusion of LCP, FID, and CLS as ranking factors means that plugin performance now directly impacts site owners' search visibility and revenue. Plugin developers who ignore performance are not just creating a poor user experience. They are actively harming their users' business outcomes. This responsibility drives the growing demand for performance-conscious plugin development and automated performance testing as part of the plugin development workflow.

The relationship between performance and security is often overlooked but critically important. Performance bottlenecks can become denial-of-service vectors when attackers identify expensive operations they can trigger repeatedly. A poorly optimized database query that takes two seconds under normal load might be weaponized to consume all available database connections. Similarly, memory-intensive operations without proper limits can be exploited to crash PHP worker processes. Performance optimization and security hardening are complementary disciplines that reinforce each other when approached holistically.

Broader Context and Best Practices

Performance optimization in WordPress plugins requires understanding the full request lifecycle, from the initial HTTP request through PHP execution, database queries, and response generation. Every millisecond added to this cycle multiplies across every page load for every visitor. A plugin that adds just 50 milliseconds of overhead might seem insignificant, but on a site serving 100,000 page views per day, that translates to nearly 1,400 hours of cumulative user waiting time per year. This perspective helps prioritize optimization efforts where they have the greatest impact on real user experience.

Database queries are the most common performance bottleneck in WordPress plugins, but not all query optimization strategies are equally effective. Adding an index speeds up read operations but slows down writes. Caching eliminates queries entirely but introduces cache invalidation complexity. Denormalization reduces JOIN operations but creates data consistency challenges. Understanding these trade-offs is essential for making informed optimization decisions rather than blindly applying generic advice. Profiling tools and query monitoring help identify which specific queries deserve optimization attention and which optimization strategy best fits each situation.

Frequently Asked Questions

How many indexes should a table have?

Most tables benefit from 3-5 indexes total. Each index accelerates specific queries but slows INSERTs/UPDATEs. Monitor performance—if adding an index doesn't improve query speed, remove it.

Should I index every WHERE clause column?

No. Index columns used in WHERE clauses, JOINs, and ORDER BY. Avoid indexing columns rarely used for filtering or columns with very low cardinality (few distinct values).

What's the difference between unique and non-unique indexes?

UNIQUE indexes prevent duplicate values and are slightly faster for equality lookups. Non-unique indexes allow duplicates and are better for filtering columns with many repeated values (like post_status or post_type).

How do I know if a query is using an index?

Run EXPLAIN on the query. If the "key" column shows an index name, the index is used. If "key" is NULL, the query doesn't use an index. If "type" is ALL, a full table scan occurs.

Can I index a TEXT column?

TEXT columns can be indexed with a prefix: INDEX idx_content (content(100)). MySQL uses only the first 100 characters for the index. This reduces index size but makes the index less selective.

How often should I optimize (OPTIMIZE TABLE)?

OPTIMIZE TABLE rebuilds the table and indexes, reclaiming deleted row space. Run it occasionally (monthly or quarterly) on large tables with many deletes. It locks the table, so run during low-traffic periods.

Conclusion

WordPress database index optimization strategy requires understanding MySQL index types, proper index design, and careful analysis of your plugin's queries. Strategic indexes transform database performance from milliseconds per query to microseconds.

Core database indexing principles:

  • Design indexes for your queries: Index columns used in WHERE, JOIN, and ORDER BY
  • Follow the ERS rule: Equality, Range, Sort column ordering
  • Use EXPLAIN: Verify queries use indexes efficiently
  • Create custom table indexes during plugin activation
  • Optimize wp_postmeta performance with proper indexing
  • Consider covering indexes for frequently run queries
  • Monitor and maintain: Use slow query logs to identify optimization opportunities

WP HealthKit's performance audits analyze your plugin's database queries and table structures. We identify missing indexes, inefficient index design, and query patterns causing unnecessary database scans.

Optimize your plugin's database performance: Upload your plugin to WP HealthKit for detailed index optimization recommendations.


Related resources:

Ready to audit your plugin?

WP HealthKit checks for all the issues in this article and 40+ more across 62 verification layers.

Comments