Query Performance

The WP_Query arguments that remove queries, why meta_query is expensive, and how autoloaded options slow every request.

Syntaxnew WP_Query( array( 'no_found_rows' => true, 'fields' => 'ids', 'update_post_meta_cache' => false ) )

Most slow WordPress pages are not slow because of one terrible query. They are slow because a loop of cheap-looking calls each hits the database, and because WP_Query does work by default that a given template does not need.

Query arguments that remove queries

  • no_found_rows (default false) — skips the total-row count WordPress uses to build pagination. If the template is not paginated, that count is a second pass over the same result set for nothing.
  • fields — '' returns WP_Post[], 'ids' returns int[], 'id=>parent' returns parent IDs keyed by post ID. The 'ids' form is the shorthand for running the full query and then wp_list_pluck( $q->posts, 'ID' ): the same IDs, without hydrating objects or priming caches.
  • update_post_meta_cache and update_post_term_cache (both default true) — one extra SELECT each. Switch them off when the loop prints only titles and excerpts.

meta_query and autoloaded options

Every meta_query clause adds a JOIN on wp_postmeta, and meta_value is a LONGTEXT with no usable index. Filtering by a taxonomy term, a post status or a column on a custom table uses an indexed field instead. Keep meta_query for genuinely per-post detail, not for things you filter archives by.

Autoloaded options are read on every request, including AJAX and REST. WordPress 6.6 stopped autoloading new options over 150 KB (filter wp_max_autoloaded_option_size) and Site Health flags a site once autoloaded options pass 800 KB. Fix an offender with wp_set_option_autoload(), added in 6.4.

Example

Example · php
<?php
// A. A list that prints titles and excerpts only. Full post objects, but
//    nothing beyond them is fetched.
$recent = new WP_Query( array(
    'post_type'              => 'post',
    'posts_per_page'         => 10,
    'no_found_rows'          => true,  // no pagination here, so skip the row count
    'update_post_meta_cache'  => false, // no SELECT against wp_postmeta
    'update_post_term_cache' => false, // no SELECT against wp_term_relationships
    'ignore_sticky_posts'    => true,
) );

// B. When you only need identifiers, ask for identifiers.
$ids = ( new WP_Query( array(
    'post_type'      => 'product',
    'posts_per_page' => 50,
    'fields'         => 'ids',   // int[] rather than WP_Post[]
    'no_found_rows'  => true,
) ) )->posts;

// Careful: the 'ids' form returns before WP_Query primes any caches, so the
// meta/term flags in A have nothing to switch off in B — and get_post_meta()
// below would otherwise fire one query per ID. Prime the whole set at once:
// update_postmeta_cache( int[] $post_ids ): array|false
update_postmeta_cache( $ids );

foreach ( $ids as $id ) {
    $sku = get_post_meta( $id, '_sku', true ); // served from the object cache
}

When to use it

  • A related-posts sidebar adds 60 queries to every single page.
  • A product archive filtered by post meta became unusable after an import.
  • Site Health reports the autoloaded options size as a performance issue.
  • You need a list of IDs to feed into another query and do not want the post objects.

More examples

Tuning the main query without touching the template

pre_get_posts changes the query WordPress already decided to run, so the template hierarchy, pagination and canonical URLs all keep working. Use it when the performance fix belongs to a route rather than to one call site — and note the case where no_found_rows is the wrong answer, because dropping it silently breaks the pager.

Example · php
<?php
add_action( 'pre_get_posts', function ( WP_Query $q ): void {
    if ( is_admin() || ! $q->is_main_query() ) {
        return;
    }

    if ( $q->is_post_type_archive( 'product' ) ) {
        // This archive template renders title and excerpt only.
        $q->set( 'update_post_meta_cache', false );
        $q->set( 'update_post_term_cache', false );

        // Deliberately NOT setting no_found_rows: the archive is paginated and
        // the pagination links need $wp_query->found_posts.
    }
} );

Replacing a meta_query with a taxonomy

The rule of thumb: if you filter or sort archives by a value, it wants to be a taxonomy term or a custom table column, not post meta. Use post meta for data you read once you already have the post. This is the single most common cause of a WordPress site that is fine at 500 posts and unusable at 50,000.

Example · php
<?php
// Slow: a JOIN on wp_postmeta, filtering an unindexed LONGTEXT column.
$q = new WP_Query( array(
    'post_type'  => 'product',
    'meta_query' => array(
        array(
            'key'   => '_availability',
            'value' => 'in-stock',
        ),
    ),
) );

// Fast: the term relationship tables are indexed for exactly this lookup.
register_taxonomy( 'availability', 'product', array(
    'public'       => false,   // not a public archive, just a queryable index
    'show_ui'      => true,
    'hierarchical' => false,
) );

$q = new WP_Query( array(
    'post_type' => 'product',
    'tax_query' => array(
        array(
            'taxonomy' => 'availability',
            'field'    => 'slug',
            'terms'    => 'in-stock',
        ),
    ),
    'no_found_rows' => true,
) );

Auditing autoloaded options

Run this on any inherited site before optimising anything else: a plugin caching a 4 MB API response in an autoloaded option costs that on every page view, every AJAX call and every REST request. Only change autoload on options you own — flipping a plugin's option can break code that assumes it is already in memory.

Example · php
<?php
// Which options are read on every single request, largest first?
global $wpdb;

$rows = $wpdb->get_results(
    "SELECT option_name, LENGTH( option_value ) AS bytes
       FROM {$wpdb->options}
      WHERE autoload IN ( 'yes', 'on', 'auto-on', 'auto' )
      ORDER BY bytes DESC
      LIMIT 20"
);
// Those four values are what WordPress 6.6+ treats as autoloaded; the list is
// filterable via 'wp_autoload_values_to_autoload'. Older rows still say 'yes'.

foreach ( $rows as $row ) {
    if ( $row->bytes > 100000 && str_starts_with( $row->option_name, 'acme_' ) ) {
        // Stop autoloading it. get_option() still returns it — it just costs a
        // query on the requests that actually ask for it.
        // wp_set_option_autoload( string $option, bool $autoload ): bool  (6.4)
        wp_set_option_autoload( $row->option_name, false );
    }
}

Discussion

  • Be the first to comment on this lesson.