La clase WP_Query es la forma universal de solicitar posts a WordPress. Se pueden distinguir dos grandes tipos de queries, el query principal, que es el query realizado para obtener el objeto principal del documento, y los queries secundarios, que son todos los demás queries accesorios.

Por ejemplo, si estamos viendo un post, el query principal será el query que realizó WordPress para obtener la información de ese post. Si además del post, hemos creado un widget para mostrar una lista de posts relacionados, el query para obtenerlos será un query secundario.

El query principal lo gestiona WordPress según la solicitud que reciba y se puede modificar a través de varios filtros, por ejemplo a través del action pre_get_posts. El resultado es una instancia de WP_Query que se almacena en la variable global $wp_query con la que se crea el loop principal.

Un query secundario es cualquier nueva instancia de WP_Query que utilicemos en nuestro theme o plugin. El loop construido con este query secundario se denomina igualmente loop secundario.

Los valores predeterminados de los diferentes parámetros de WP_Query están pensados para el query principal y en loops secundarios se pueden mejorar bastante desde una perspectiva de rendimiento. A esta optimización centrada en loops secundarios vamos a dedicar este tutorial.

Optimizando un loop secundario

Para desarrollar el tutorial vamos a suponer que tenemos una web de noticias que gestionamos a través del custom post type news. Cuándo visitamos una noticia, queremos mostrar un listado con las 5 noticias más recientes. Cómo requisito, el listado mostrará el thumbnail de la noticia y el título del post.

Bien, podríamos construir este sencillo loop secundario:

$args = [
  'post_type'     => 'news',
  'post_per_page' => 5,
];
$latest_news = new WP_Query( $args );

if( $latest_news->have_posts() ) {
  ?>
  <ul>
  <?php
  while( $latest_news->have_posts() ) {
    $latest_news->the_post();
    ?>
    <li>
      <h3><?php the_title(); ?></h3>
      <?php the_post_thumbnail( 'thumbnail' ); ?>
    </li>
    <?php
  }
  wp_reset_postdata();
  ?>
  </ul>
  <?php
}

Empecemos a mejorar este loop.

1

no_found_rows: true

Cómo en nuestro listado no utilizamos paginación, podemos configurar este parámetro para suprimir el uso de SQL_CALC_FOUND_ROWS:

$args = [
  'post_type'     => 'news',
  'post_per_page' => 5,
  'no_found_rows' => true,
];
2

update_post_term_cache: false

Cómo en nuestro listado no necesitamos información sobre la clasificación de las noticias (categorías y demás), no hay necesidad de actualizar la información que WordPress pueda tener en caché al respecto:

$args = [
  'post_type'             => 'news',
  'post_per_page'         => 5,
  'no_found_rows'         => true,
  'update_post_term_cache' => false,
];
3

update_post_meta_cache: false

Al igual que antes, si no necesitamos información meta del post (como los custom fields), podemos suprimir la actualización de la cache de esta información:

$args = [
  'post_type'             => 'news',
  'post_per_page'         => 5,
  'no_found_rows'         => true,
  'update_post_term_cache' => false,
  'update_post_meta_cache' => false,
];

En nuestro ejemplo no lo voy a hacer, ya que necesitamos el meta field _thumbnail_id que almacena el ID de la imagen destacada de cada noticia.

4

Limitar la información de los posts que se obtiene de la bd

En algunos casos, para queries de un alto número de posts, puede ser interesante utilizar el filtro posts_fields para limitar los campos que se leerán en la tabla wp_posts:

Por ejemplo, si sólo necesitamos el ID y el post_title:

add_filter( 'posts_fields', function ( $fields, $query ) {

  global $wpdb;
  
  // Sólo si nuestra variable personaliza no es false
  if ( $query->get( 'limit_fields' ) ) {
    $fields = "$wpdb->posts.ID, $wpdb->posts.post_title";
  }

  return $fields;

}, 10, 2);

$args = [
 'post_type'            => 'news',
 'post_per_page'         => 5,
 'no_found_rows'         => true,
 'update_post_term_cache' => false,
 // Añadimos nuestra variable personalizada
 'limit_fields'          => true,
];

Si sólo se necesitan los IDs, se puede utiliza directamente el parámetro fields => 'ids':

$args = [
 'fields' => 'ids',
];
5

update_post_thumbnail_cache()

Cuándo se realiza el loop principal, WordPress utiliza la función update_post_thumbnail_cache() para obtener la información de las imágenes destacadas de todos los posts del loop de una sóla vez, almacena esta información en caché y la utiliza luego para mostrar la imagen destacada cuándo se solicite. Se evita así tener que hacer un query adicional por cada post thumbnail en el loop.

Pero esta función no es utilizada automáticamente en loops secundarios, así que la tenemos que poner nosotros, pero sólo es necesario si nuestro loop secundario muestra las imágenes destacadas de los posts, como es nuestro caso:

$args = [
  'post_type'             => 'news',
  'post_per_page'         => 5,
  'no_found_rows'         => true,
  'update_post_term_cache' => false,
  'suppress_filters'       => true,
];
$latest_news = new WP_Query( $args );

if( $latest_news->have_posts() ) {

  update_post_thumbnail_cache( $latest_news );

  ?>
  <ul>
  <?php

  while( $latest_news->have_posts() ) {
    $latest_news->the_post();
    ?>
    <li>
      <h3><?php the_title(); ?></h3>
      <?php the_post_thumbnail( 'thumbnail' ); ?>
    </li>
    <?php
  }

  wp_reset_postdata();

  ?>
  </ul>
  <?php

}

En lugar de añadir update_post_thumbnail_cache() a cada loop secundario, podemos utilizar el filtro the_posts junto a una variable personalizada en WP_Query:

$args = [
  // Nuestra variable personalizada
  'cyb_upadate_post_thumbnail_cache'  => true,
];
$latest_news = new WP_Query( $args );

Y luego el filtro the_posts:

add_filter( 'the_posts', 'cyb_prime_post_thumbnails_cache', 10, 2 );
function cyb_prime_post_thumbnails_cache( $posts, $query ) {

  // Exlucir el query principal y comprobar
  // si está nuestra variable personalizada y valida como true
  if( ! $query->is_main_query() && $query->get( 'cyb_upadate_post_thumbnail_cache' ) ) {
    update_post_thumbnail_cache( $query );
  }

  return $posts;

}
6

A tener en cuenta si utilizas get_posts()

La función get_posts() utiliza internamente WP_Query y establece algunos parámetros en sus valores idóneos para loops secundarios. Por ejemplo, no_found_rows => true.

Sin embago, también establece suppress_filters => true y esto excluye los resultados del sistema de caché de WordPress. Así que acuérdate de pasar suppress_filters => false si utilizas get_posts(). Lo mismo ocurre con wp_get_recent_posts() y get_children().

7

Queries a evitar

La búsqueda en varias tablas de una base de datos require de JOINS, lentos por naturaleza. Por este motivo se deben evitar queries que impliquen múltpiples JOINS.

  1. Evita queries con mútliples taxonomías
  2. Evita queries basados en post meta
  3. Evita queries basados en post meta y taxonomías a la vez
  4. Evita queries que contengan comprobaciones tipo not_in (post__not_in, category__not_in, etc).

Un ejemplo de query nefasto en cuanto a performance:

$args = [
    // ....
   'post_type'    => 'property',
   'post__not_in' => [ 215, 321, 985 ]
   'tax_query'  => [
        [
           'taxonomy'   => 'city',
           'terms'      => array( 45, 78, 112 ),
           'compare'    => 'NOT IN'
        ],
        [
           'taxonomy'   => 'type',
           'terms'      => 2,
        ],
        [
           'taxonomy'   => 'transaction',
           'terms'      => 5,
        ]
   ],
   'meta_query' => [
       [
           'key'     => 'price',
           'value'   => array( 50000, 100000 ),,
           'compare' => 'BETWEEN'
       ]
   ]
];
$query = new WP_Query( $args );

Quiero que le prestéis especial atención a los queries basados en meta fields. Los campos meta están destinados a almacenar características únicas de los posts, no para crear criterios de clasificación, para eso están las taxonomías. Si te ves haciendo cosas como:

$args = [
   // ....
   'post_type'  => 'people',
   'meta_query' => [
       [
           'key'   => 'country',
           'value' => 'Spain'
       ]
   ]
];
$query = new WP_Query( $args );

Considera pasarte a una taxonomía y utilizar el term_id, que es la columna con índice en la tabla, en lugar del slug o el nombre:

$args = [
  // ....
  'post_type'  => 'people',
  'tax_query'    => [
      [
          'taxonomy'   => 'country',
          'terms'      => 45,
          'field'      => 'term_id'
      ]
   ]
];
$query = new WP_Query( $args );
8

Utiliza cache

Si no puedes optimizar el query y evitar las concidiones lentas, deberías cachear los resultados utilizando el API de caché de objetos de WordPress (o el Transient API):

// Obtener datos desde la caché
$resultados = wp_cache_get( 'cyb_cache_key', 'cyb_cache_group' );

// Si la caché no es válida
if( false === $resultados ) {

  // Volver a ejecutar el query
  $args = [
    // ....
  ];
  $resultados = new WP_Query( $args );

  // Y si hay resultados, almacenarlos en caché
  if ( ! is_wp_error( $resultados ) && $resultados->have_posts() ) {
     wp_cache_set( 'cyb_cache_key', $resultados, 'cyb_cache_group' );
  }

}
9

Recursos

Para finalizar os dejo con algunos enlaces en los que se comentan estos y otros detalles relacionados con el rendimiento y el desarrollo con WordPress, aunque no necesariamente específicos para queries secundarios, son muy interesnates:

  1. Stéphane Boisvert (1 de febrero de 2016). Introducción a WordPress Code Security y Performance. WordPress Barcelona. (Diapositivas)
  2. Engineering Best Practices: PHP. 10up
  3. WordPress Uncached functions. WordPress VIP Documentation.

¿Alguna idea más para optimizar WP_Query y loops secundarios? Me encataría escucharlas y añadirlas a la lista.