`WP_Query` es el constructor SQL detrás de cada petición frontal: archivos, posts individuales, endpoints REST y bucles de tienda WooCommerce. Cuando una página va lenta, la causa suele ser una de tres cosas: un `meta_query` contra una `meta_key` sin índice, un `posts_per_page` sin límite, o `SQL_CALC_FOUND_ROWS` ejecutándose en cada carga cuando la página no muestra ningún total.
Orden de magnitud sobre un caso real de una tienda WooCommerce alojada en Webempresa con 50.000 productos: un meta_query contra _stock_status sin índice sobre (meta_key, post_id) se mantiene entre 1,5 y 3 segundos. Tras añadir el índice compuesto, la misma consulta baja a 50–200 ms. La consulta es idéntica; lo único que cambió fue el índice.
El resto de esta guía es la memoria muscular para evitar esos tres modos de fallo y para recuperar el control cuando heredas un tema que ya los tiene cableados.
Parte 1: Arquitectura de una solicitud
Para dominar WP_Query, debes entender que sucede cuando lo instancias. WordPress ejecuta los siguientes pasos:
- Análisis de argumentos: Convirtiendo tu array de argumentos en un formato estandarizado
- Generación SQL: Construyendo una sentencia SQL SELECT compleja
- Ejecucion de consulta: Enviando la solicitud a la base de datos
- Llenado del objeto: Poblando el objeto
WP_Querycon objetos de publicación y metadatos
Dentro del objeto WP_Query
Cuando ejecutas $query = new WP_Query($args), no solo obtienes un array de publicaciónes. Obtienes un objeto masivo con propiedades criticas:
$query->posts: Array de objetosWP_Post$query->post_count: Número de publicaciónes en la página actual$query->found_posts: Total de publicaciónes que coinciden con los criterios$query->max_num_pages: Total de páginas de resultados$query->query_vars: Los argumentos que WordPress realmente uso
1. El Loop principal (contexto global)
Es el loop activado por la solicitud de URL. WordPress maneja la instanciacion; tu solo iteras:
if ( have_posts() ) :
while ( have_posts() ) : the_post();
get_template_part( 'template-parts/content', get_post_type() );
endwhile;
endif;
2. El Loop secundario (consultas personalizadas)
Se usa para listas tipo “Posts relacionados”, widgets de últimas noticias o cualquier sección que muestre contenido fuera de la consulta principal. En catálogos reales conviven tres constructores que no son intercambiables:
new WP_Query( $args ), objeto completo, exponefound_posts,max_num_pagesy soporta iteración conthe_post(). Úsalo cuando necesites paginación o acceso completo a las template tags.get_posts( $args ), envoltorio ligero que devuelve un array de objetosWP_Post. Por defecto fija'suppress_filters' => truey'no_found_rows' => true, lo que es más rápido pero salta los filtrosposts_*. Idóneo para listas cortas y de tamaño fijo en barras laterales.query_posts(), destructivo: sobrescribe la consulta principal global, rompe los condicionalesis_*()del resto de la página y obliga a reconstruir la consulta principal conwp_reset_query(). El core lo desaconseja desde 2010 (sigue presente solo por retrocompatibilidad). No lo uses. Si lo encuentras en un tema heredado, sustitúyelo porpre_get_postspara la consulta principal ynew WP_Querypara todo lo demás.
$args = [
'post_type' => 'post',
'posts_per_page' => 5,
'no_found_rows' => true, // CRITICO PARA EL RENDIMIENTO
];
$query = new WP_Query( $args );
if ( $query->have_posts() ) {
while ( $query->have_posts() ) {
$query->the_post();
// Renderizar título, extracto, etc.
}
wp_reset_postdata(); // OBLIGATORIO para restaurar el objeto global $post
}
Parte 2: Asesinos de rendimiento (los “no hagas” de 2026)
1. posts_per_page => -1 es un disparo en el pie
-1 indica a WP_Query que cargue cada fila coincidente como objeto WP_Post hidratado y, a continuación, ejecute update_post_meta_cache y update_post_term_cache sobre todas. En un CPT que pasó de 50 entradas a 5.000 en dos años, el código que tardaba 80 ms ahora reserva más de 200 MB de memoria PHP y agota el memory_limit típico de los planes compartidos de Raiola Networks o Webempresa.
La solución es un límite duro alineado con la superficie renderizada:
- Widget lateral con cinco elementos →
'posts_per_page' => 5. - Generador de sitemap que sí necesita todas las filas → procesa por lotes con
pageddentro de un bucle, no con-1. 200 IDs por lote es un techo seguro en hosting habitual. - Herramientas internas de exportación donde aceptas presión de memoria → aun así, fija
'fields' => 'ids'para no hidratar objetos completos.
2. SQL_CALC_FOUND_ROWS corre incluso cuando nadie lee el total
Sin no_found_rows, WP_Query añade SQL_CALC_FOUND_ROWS al SELECT y emite un SELECT FOUND_ROWS() adicional. MySQL sigue contando después del LIMIT, y sobre una tabla wp_posts de 200.000 filas con un JOIN de tax_query esto suele añadir 80–250 ms por petición, dependiendo del estado de la caché.
Si la página no muestra “Página 3 de 47” ni un total, fija 'no_found_rows' => true. La pregunta natural (“pero quiero paginación con enlaces siguiente y anterior”) tiene respuesta sin found_posts: pide posts_per_page + 1 y renderiza posts_per_page; la fila extra señala que existe una página siguiente. Es exactamente el patrón de paginación de Twitter y de Hacker News.
Para archivos paginados que sí muestran un total real (“Mostrando 1–10 de 4.200 artículos”), conserva el conteo, pero guárdalo en un transient de cinco minutos con clave compuesta por la firma de filtros. El total raramente cambia en esa ventana; el escaneo del JOIN no necesita repetirse en cada carga.
3. Ordenar por aleatorio (orderby => rand)
La operación más costosa en MySQL. La base de datos crea una tabla temporal, asigna un número aleatorio a cada fila y luego las ordena.
Solución:
- Obtener IDs de las últimás 50 publicaciónes
- Seleccionar 5 IDs aleatorios en PHP
- Ejecutar segunda consulta con
post__in => $random_ids
4. meta_query contra meta_key sin indexar
Es la causa más frecuente de archivos lentos en WooCommerce heredados, sobre todo en catálogos textiles españoles tipo Adolfo Domínguez o casos similares con miles de SKU. wp_postmeta solo trae un índice sobre meta_key, demasiado poco selectivo cuando WordPress filtra por meta_key = '_stock_status' AND meta_value = 'instock': MySQL sigue recorriendo todas las filas con esa clave.
Caso real: una tienda alojada en Stackscale con 50.000 productos. La página de catálogo enganchaba pre_get_posts para añadir un meta_query por _stock_status y un campo personalizado _marca. El TTFB P95 era de 4,1 segundos. SAVEQUERIES mostraba que el 80% del tiempo se quemaba en un JOIN contra wp_postmeta. La corrección:
ALTER TABLE wp_postmeta
ADD INDEX idx_meta_key_post_id (meta_key(32), post_id);
Tras el índice, la misma consulta bajó a 180 ms. No tocó ni una línea de PHP. Para valores que sí participan en comparaciones de rango ('compare' => '>', 'compare' => 'BETWEEN'), también necesitas índice sobre meta_value, normalmente un índice de prefijo, porque la columna es LONGTEXT.
Cuando el espacio de valores es fijo (tallas, colores, slugs de marca), no uses meta_query en absoluto. Registra una taxonomía personalizada. wp_term_relationships ya está indexada por object_id y term_taxonomy_id, así que el mismo filtro que tardaba 1,8 s como meta_query corre en 30–60 ms como tax_query.
5. Los JOIN de tax_query se acumulan a escala
Un tax_query con una sola taxonomía añade un JOIN contra wp_term_relationships y otro contra wp_term_taxonomy. Por encima de 200.000 posts, cada taxonomía adicional en la misma consulta añade 50–200 ms. Las páginas de filtros facetados que combinan seis taxonomías (talla + color + marca + temporada + material + rango de precio) suelen quedarse entre 800 ms y 2 s. Las opciones razonables son: desnormalizar en una única taxonomía de facetas, pasar a FacetWP o SearchWP (que indexan fuera de tabla), o llevar las facetas a un motor de búsqueda, ElasticPress sobre Elasticsearch o Algolia para catálogos de solo lectura como los que usan Mango y otras tiendas de moda en España.
Parte 3: Lógica de consulta avanzada
1. Relaciones con tax_query
$args = [
'post_type' => 'product',
'tax_query' => [
'relation' => 'AND',
[
'taxonomy' => 'color',
'field' => 'slug',
'terms' => [ 'rojo', 'azul' ],
'operator' => 'IN',
],
[
'taxonomy' => 'talla',
'field' => 'slug',
'terms' => [ 'grande' ],
],
],
];
2. Consultas de fecha (rango dinámico)
$args = [
'date_query' => [
[
'after' => '1 de enero de 2025',
'before' => [
'year' => 2026,
'month' => 12,
'day' => 31,
],
'inclusive' => true,
],
],
];
Parte 4: Cache - El secreto de la velocidad
En 2026, un sitio de alto tráfico raramente deberia tocar la base de datos para listas estaticas.
1. Usando la API de Transients
$cache_key = 'home_trending_jobs';
$results = get_transient( $cache_key );
if ( false === $results ) {
$results = new WP_Query( [ /* Args complejos */ ] );
set_transient( $cache_key, $results, HOUR_IN_SECONDS );
}
2. Desactivar el priming de caché en una consulta concreta
Tras obtener los IDs, WP_Query llama a update_post_meta_cache() y update_post_term_cache() para cargar en bloque cada fila de meta y de términos en el object cache. Es la opción correcta para un loop de plantilla normal que va a llamar a get_post_meta() y get_the_terms(). No lo es para un loop que solo renderiza título y permalink.
$args = [
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'no_found_rows' => true,
'fields' => 'ids',
];
Para un archivo de 50 posts con 30 filas de meta cada uno, saltarse el priming de meta evita leer y cachear 1.500 filas en una sola petición. Combinado con 'fields' => 'ids', ni siquiera hidratas objetos WP_Post, útil para generadores de sitemap, listas de IDs de relacionados en barra lateral, o widgets del dashboard administrativo. Importante para cumplimiento AEPD: cuando registras campos de auditoría sobre quién consulta qué (logs RGPD por consulta), evita que ese hook engorde con get_post_meta() adicionales por fila renderizada, el coste se multiplica por el tamaño de la página.
Parte 5: Páginación en loops personalizados
Por que la páginación devuelve 404: WordPress no sabe en que página estas para un WP_Query personalizado.
La solución:
$paged = ( get_query_var( 'paged' ) ) ? get_query_var( 'paged' ) : 1;
if ( is_front_page() ) {
$paged = ( get_query_var( 'page' ) ) ? get_query_var( 'page' ) : 1;
}
$args = [
'paged' => $paged,
'posts_per_page' => 10,
];
Parte 6: WP_Query en stacks modernos (REST y headless)
Si construyes un frontend React usando WordPress headless, no usas WP_Query directamente en JS, pero la REST API lo usa en el backend.
Personalizando resultados de REST API:
add_filter( 'rest_post_query', function( $args, $request ) {
$exclude = $request->get_param( 'exclude_ids' );
if ( ! empty( $exclude ) ) {
$args['post__not_in'] = explode( ',', $exclude );
}
return $args;
}, 10, 2 );
Parte 7: Depurando consultas lentas
Las cuatro herramientas que de verdad encuentran la respuesta:
- Query Monitor (John Blackbourn). Instálalo en staging, nunca en producción. El panel “Queries” agrupa por componente, así que ves enseguida si el TTFB de 2 segundos venía de tu tema, del core de WooCommerce o de un plugin de terceros que enganchó
pre_get_posts. La pestaña de “duplicate queries” caza los bucles N+1 donde una template tag dentro dethe_post()vuelve a consultar la base de datos por cada fila. SAVEQUERIES. Definedefine( 'SAVEQUERIES', true );enwp-config.phpsolo en staging, duplica el uso de memoria. WordPress llena entonces$wpdb->queriescon tripletas[ sql, duración, callstack ].error_log( print_r( $wpdb->queries, true ) )enshutdownproduce la traza canónica de “qué corrió y cuánto tardó”.$query->requestconEXPLAIN. Después denew WP_Query(),echo $query->request;imprime el SQL real generado por WordPress. Pasa esa cadena porEXPLAINen TablePlus omysql -e. Buscatype: ALL(escaneo completo de tabla),Using filesortoUsing temporary, son las filas que necesitan índice.- WP-CLI. En entornos de hosting español como Webempresa con LiteSpeed, Raiola Networks con PHP-FPM o Stackscale dedicado, WP-CLI suele estar disponible por SSH:
wp db query --skip-column-names "EXPLAIN $SQL"ejecutaEXPLAINcontra la BD viva sin la sobrecarga del navegador.wp post list --post_type=product --fields=ID --posts_per_page=10reproduce la lógica de consulta desde un entorno determinista, útil cuando la página lenta queda detrás de un paywall o login.
Heurística para priorizar: cualquier consulta por encima de 50 ms es sospechosa con caché caliente, cualquier cosa sobre 200 ms está rota. El presupuesto total para una página WordPress renderizada en servidor dentro del rango “Bueno” de Core Web Vitals es de unos 600 ms de TTFB; si una sola WP_Query se come 250 ms, te has salido del presupuesto antes de que la plantilla empiece a renderizar. La comunidad WordPress Madrid y WordPress Barcelona suele compartir capturas de Query Monitor exactamente con este patrón en sus meetups mensuales.
Checklist operativa
Cuando una página va lenta, recorre la lista en orden, los tres primeros puntos explican la mayoría de las lentitudes de WP_Query en sitios WordPress en producción.
- Lanza Query Monitor sobre la URL lenta. Ordena por tiempo. La consulta más larga es casi siempre la que hay que arreglar.
- Comprueba si los filtros
meta_queryapuntan a unameta_keysin indexar. Si es así, añadeINDEX (meta_key(32), post_id)sobrewp_postmeta. - Añade
'no_found_rows' => truea cada loop secundario que no muestre un total real. - Sustituye
posts_per_page => -1por un límite duro o procesa por lotes conpaged. - Reemplaza
query_posts()porpre_get_posts(consulta principal) onew WP_Query(loops secundarios). - En loops que solo renderizan título y enlace, usa
'fields' => 'ids'yupdate_post_meta_cache => false. - Después de loops personalizados, llama a
wp_reset_postdata()para restaurar el$postglobal. - Si el mismo WP_Query corre en cada carga y los datos cambian poco, envuélvelo en un transient con clave por la firma de los args.
Lectura relacionada: optimización de velocidad WordPress.
Por qué importa esto en 2026 no es abstracto. El informe de Cloudflare 2025 sobre peso de página sitúa el TTFB mediano de e-commerce sobre WordPress en hosting compartido en 1,4 s, frente al umbral “Bueno” de Google de 800 ms. Las correcciones a nivel de consulta de este documento suelen ser la vía más barata para recuperar ese presupuesto, más barata que cambiar a un plan dedicado en Stackscale, más barata que añadir CDN, y mucho más barata que rehacer el sitio en headless.
¿Necesitas ayuda con desarrollo WordPress o mantenimiento WordPress? Contáctanos.


