WordPress Performance
Performance optimization patterns for WordPress plugins, themes, and live sites.
Critical Rules
- Never query without limits —
posts_per_page => -1and unbounded$wpdbqueries are the most common cause of memory exhaustion on production sites. - Cache expensive queries — any query that runs on every page load and doesn't change per-request must be cached with transients or the object cache.
- Use
no_found_rows— set'no_found_rows' => trueon anyWP_Querythat doesn't need pagination; this skipsSQL_CALC_FOUND_ROWSwhich is expensive on large tables. - Avoid meta queries on unindexed keys —
meta_queryonwp_postmetaperforms full table scans unless you add custom indexes. Consider a custom table for heavily queried data. - Defer heavy work — operations on
initrun on every request. Useadmin_initfor admin-only work,rest_api_initfor REST-only, andwp_loadedor later hooks when possible. - Prime caches, don't loop-query — use
update_post_meta_cache(),update_post_caches(), or_prime_post_caches()to batch-load metadata instead of callingget_post_meta()in a loop.
Query Performance Patterns
Efficient WP_Query
// Good — limited, cache-friendly, no unnecessary data:
$query = new WP_Query( [
'post_type' => 'product',
'posts_per_page' => 20,
'no_found_rows' => true, // Skip pagination count query.
'update_post_meta_cache' => false, // Skip if not reading meta.
'update_post_term_cache' => false, // Skip if not reading terms.
'fields' => 'ids', // Return only IDs when full objects aren't needed.
] );
// Bad — unbounded, forces full table scan:
$query = new WP_Query( [
'post_type' => 'product',
'posts_per_page' => -1, // Never do this.
] );
N+1 Query Prevention
// Bad — N+1 pattern (1 query per post in the loop):
foreach ( $posts as $post ) {
$price = get_post_meta( $post->ID, '_price', true ); // Query per iteration.
}
// Good — prime the cache first, then loop reads from cache:
$post_ids = wp_list_pluck( $posts, 'ID' );
update_meta_cache( 'post', $post_ids ); // Single query loads all meta.
foreach ( $posts as $post ) {
$price = get_post_meta( $post->ID, '_price', true ); // Reads from cache.
}
Batch Operations
// Bad — individual inserts in a loop:
foreach ( $items as $item ) {
$wpdb->insert( $table, $item ); // N queries.
}
// Good — single bulk insert:
$values = [];
foreach ( $items as $item ) {
$values[] = $wpdb->prepare( '(%d, %s, %f)', $item['user_id'], $item['status'], $item['total'] );
}
if ( $values ) {
$wpdb->query( "INSERT INTO {$table} (user_id, status, total) VALUES " . implode( ', ', $values ) );
}
Caching Strategies
Transient Caching Pattern
function myplugin_get_featured_products(): array {
$cache_key = 'myplugin_featured_products';
$data = get_transient( $cache_key );
if ( false !== $data ) {
return $data;
}
$query = new WP_Query( [
'post_type' => 'product',
'posts_per_page' => 12,
'meta_key' => '_featured',
'meta_value' => 'yes',
'no_found_rows' => true,
'fields' => 'ids',
] );
$data = $query->posts;
set_transient( $cache_key, $data, HOUR_IN_SECONDS );
return $data;
}
// Invalidate when products change:
add_action( 'save_post_product', function (): void {
delete_transient( 'myplugin_featured_products' );
} );
Object Cache for Per-Request Deduplication
function myplugin_get_settings(): array {
$cached = wp_cache_get( 'settings', 'myplugin' );
if ( false !== $cached ) {
return $cached;
}
$settings = get_option( 'myplugin_settings', [] );
wp_cache_set( 'settings', $settings, 'myplugin' );
return $settings;
}
When to Use Each Layer
| Scenario | Strategy |
|---|---|
| Same data fetched multiple times per request | wp_cache_* (object cache) |
| Expensive query, result valid for minutes/hours | set_transient() |
| External API response | set_transient() with timeout matching API rate limits |
| Data that changes on save | Transient + delete_transient() on save_post |
| User-specific data | Transient with user ID in key, or get_user_meta() |
| Full-page output | Consider wp_cache_* with a persistent backend (Redis/Memcached) |
Hook Performance
Defer Initialization
// Bad — runs on every request including REST, AJAX, cron:
add_action( 'init', 'myplugin_heavy_init' );
// Good — scope to context:
add_action( 'admin_init', 'myplugin_admin_setup' ); // Admin only.
add_action( 'rest_api_init', 'myplugin_register_routes' ); // REST only.
add_action( 'template_redirect', 'myplugin_frontend' ); // Frontend only.
// Good — conditional loading:
add_action( 'init', function (): void {
if ( ! is_admin() && ! wp_doing_ajax() && ! wp_doing_cron() ) {
// Frontend-only logic.
}
} );
Lazy Loading
// Bad — always loads all classes:
require_once __DIR__ . '/includes/class-admin.php';
require_once __DIR__ . '/includes/class-reports.php';
require_once __DIR__ . '/includes/class-import.php';
// Good — load only when needed:
add_action( 'admin_menu', function (): void {
require_once __DIR__ . '/includes/class-admin.php';
new Myplugin_Admin();
} );
// Best — PSR-4 autoloader via Composer:
require_once __DIR__ . '/vendor/autoload.php';
Autoload Optimization
Options with autoload = yes are loaded into memory on every page load via a single SELECT from wp_options. Large or rarely-used options bloat this query.
// Bad — large data autoloaded:
add_option( 'myplugin_log_history', $huge_array ); // Defaults to autoload = yes.
// Good — disable autoload for large or infrequent data:
add_option( 'myplugin_log_history', $huge_array, '', false );
// Or with update_option (autoload param is 4th arg since WP 4.2):
update_option( 'myplugin_log_history', $huge_array, false );
Rule of thumb: autoload only small, frequently-accessed options (settings, feature flags). Disable autoload for logs, large serialized arrays, and data accessed only on specific pages.
Database Indexing
// Add indexes to custom tables on activation:
register_activation_hook( __FILE__, function (): void {
global $wpdb;
$table = $wpdb->prefix . 'myplugin_orders';
// Check before adding to make activation idempotent.
$index_exists = $wpdb->get_var( $wpdb->prepare(
'SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND INDEX_NAME = %s',
DB_NAME, $table, 'status_created_idx'
) );
if ( ! $index_exists ) {
$wpdb->query( "ALTER TABLE {$table} ADD INDEX status_created_idx (status, created_at)" );
}
} );
Indexing Post Meta for Queries
If you frequently filter by a specific meta key, consider adding an index:
-- Only add this if meta_query on this key is a known bottleneck:
ALTER TABLE wp_postmeta ADD INDEX meta_key_value_idx (meta_key(50), meta_value(50));
Warning: modifying core tables is risky and may conflict with updates. Prefer custom tables for heavily queried structured data.
Anti-Patterns
| Anti-Pattern | Impact | Fix |
|---|---|---|
posts_per_page => -1 |
Loads unlimited posts into memory | Set a reasonable limit or paginate |
get_post_meta() in a loop |
N+1 queries | update_meta_cache() before loop |
Heavy logic on init |
Runs on every request | Use context-specific hooks |
get_option() on every function call |
Redundant queries (without persistent cache) | Cache in a static variable or wp_cache_* |
| Large serialized arrays in autoloaded options | Bloats the autoload query | Set autoload to false |
$wpdb->query() inside foreach |
N inserts instead of 1 | Batch insert with concatenated VALUES |
LIKE '%search%' on large tables |
Cannot use index (leading wildcard) | Full-text search index or prefix-only LIKE |
Missing no_found_rows |
Extra COUNT query when pagination isn't needed | Set true on non-paginated queries |
wp_remote_get() on every page load |
Blocks rendering on external service | Cache response with transient |
| Autoloading large options | Loaded on every request even when unused | add_option() with autoload false |
For query analysis and EXPLAIN usage, see reference/query-optimization.md.
For caching architecture and invalidation strategies, see reference/caching-strategies.md.