Cualquier desarrollador WordPress sabe lanzar un bucle sencillo. El problema empieza cuando el cliente pide un filtro compuesto: productos de la colección de temporada dentro de una categoría de color, sin los artículos marcados como no disponibles, ordenados por un campo personalizado. Ahí cat=5 y query_posts() acaban en el debug.log o en producción bajo carga.
Esta guía es práctica. Montamos tax_query y meta_query, configuramos relation AND/OR, activamos no_found_rows y fields => ids, esquivamos las trampas de índices en wp_postmeta y, al final, revisamos la consulta en Query Monitor y en la caché de objetos. El código está pensado para WordPress 6.x; los patrones también funcionan en versiones 6.0+ anteriores, siempre que no mezcles parámetros heredados con la sintaxis de arrays más reciente.
Cómo funciona tax_query en WP_Query
Olvídate de cat, tag_id y category__and como API por defecto. Esos atajos siguen funcionando, pero no escalan a lógica anidada. tax_query es el estándar: cada cláusula es un array con taxonomy, field, terms y, opcionalmente, operator e include_children.
Relación AND entre taxonomías
Quieres entradas del CPT movie que estén a la vez en el género action y en el año 2026:
$args = [
'post_type' => 'movie',
'posts_per_page' => 12,
'tax_query' => [
'relation' => 'AND',
[
'taxonomy' => 'genre',
'field' => 'slug',
'terms' => 'action',
],
[
'taxonomy' => 'year',
'field' => 'slug',
'terms' => '2026',
],
],
];
$query = new WP_Query( $args );El relation del nivel superior solo afecta a sus hijos directos. Sin un 'relation' => 'AND' explícito, WordPress asume AND de todos modos. Aun así conviene escribirlo, porque en la revisión de código deja clara la intención.
Relación OR dentro de una taxonomía
Cuando una lista de términos debe funcionar como “cualquiera de”, basta una cláusula con un array terms y el operator por defecto, IN. Las cláusulas separadas con relation => OR hacen falta cuando combinas taxonomías distintas u operadores distintos (IN frente a NOT IN).
$args = [
'post_type' => 'product',
'tax_query' => [
'relation' => 'OR',
[
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => [ 'on-sale', 'clearance' ],
],
[
'taxonomy' => 'product_label',
'field' => 'slug',
'terms' => 'last-pieces',
],
],
];Anidar grupos (AND alrededor de OR)
Un filtro de catálogo real suele tener esta forma: (categoría A OR categoría B) AND (color C) AND NOT estado descatalogado. En tax_query lo resuelves con un array anidado que tiene su propio relation:
$args = [
'post_type' => 'product',
'tax_query' => [
'relation' => 'AND',
[
'relation' => 'OR',
[
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => [ 't-shirts', 'hoodies' ],
],
[
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => 'accessories',
],
],
[
'taxonomy' => 'pa_color',
'field' => 'slug',
'terms' => 'red',
],
[
'taxonomy' => 'product_visibility',
'field' => 'name',
'terms' => [ 'outofstock', 'exclude-from-catalog' ],
'operator' => 'NOT IN',
],
],
];Cada grupo anidado genera un JOIN aparte a wp_term_relationships. Tres niveles de anidamiento en un catálogo con cientos de miles de filas ya son señal para medir con EXPLAIN. No para “simplificar por marketing”, sino para saber si MySQL elige los índices.
Filtrar por campos personalizados con meta_query
meta_query filtra sobre wp_postmeta. Cada cláusula tiene key y, opcionalmente, value, compare y type. type (NUMERIC, DECIMAL, DATE, CHAR, BINARY) determina el CAST en SQL. Sin NUMERIC, la comparación entre “10” y “2” es lexicográfica: '2' > '10' en CHAR.
Cláusula básica con CAST
$args = [
'post_type' => 'product',
'meta_query' => [
[
'key' => '_stock',
'value' => 0,
'compare' => '>',
'type' => 'NUMERIC',
],
],
];EXISTS y NOT EXISTS
A veces no te interesa el valor, solo si la clave existe. compare => 'EXISTS' (y NOT EXISTS) no necesita value. Es más barato que un LIKE sobre una cadena vacía y se lee mejor en la revisión de código.
$args = [
'post_type' => 'event',
'meta_query' => [
'relation' => 'AND',
[
'key' => 'event_start',
'compare' => 'EXISTS',
],
[
'key' => 'event_cancelled',
'compare' => 'NOT EXISTS',
],
],
];BETWEEN, IN y LIKE en meta_query
BETWEEN necesita un array de dos valores. IN, una lista. LIKE con % a ambos lados obliga a recorrer todo meta_value, porque el índice sobre meta_key no basta para buscar en mitad de un valor. Si filtras por un fragmento de texto en meta, plantéate una taxonomía aparte o una columna desnormalizada en una tabla propia. LIKE '%foo%' sobre un millón de filas de wp_postmeta es un punto caliente clásico de trac.wordpress.org y de los tickets de rendimiento del core.
Relación AND/OR en meta_query
La sintaxis es simétrica a la de tax_query. Puedes anidar grupos. Las cláusulas con nombre (una clave de texto en lugar de un índice numérico) te permiten ordenar después con un orderby que apunta al nombre de la cláusula. Es el patrón oficial de la documentación de WP_Query, no un truco.
$args = [
'post_type' => 'product',
'meta_query' => [
'relation' => 'AND',
'stock_clause' => [
'key' => '_stock_status',
'value' => 'instock',
'compare' => '=',
],
'rating_clause' => [
'key' => '_wc_average_rating',
'value' => 4,
'compare' => '>=',
'type' => 'DECIMAL',
],
],
'orderby' => [
'rating_clause' => 'DESC',
'date' => 'DESC',
],
];Combinar tax_query y meta_query en una sola consulta
Los dos argumentos funcionan a la vez. WordPress los une en un único SELECT con JOIN a términos y meta. Escenario típico de catálogo: una taxonomía de categorías más el campo _featured o un rango de fechas.
$args = [
'post_type' => 'product',
'posts_per_page' => 24,
'tax_query' => [
[
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => 't-shirts',
],
],
'meta_query' => [
'relation' => 'AND',
[
'key' => '_stock',
'value' => 0,
'compare' => '>',
'type' => 'NUMERIC',
],
[
'key' => '_featured',
'value' => 'yes',
'compare' => '=',
],
],
];Nota práctica: cada JOIN de meta adicional es otro recorrido de wp_postmeta. Dos filtros meta en una tienda WooCommerce grande pueden alargar el tiempo de respuesta mucho más rápido que tres taxonomías, porque term_relationships tiene una estructura de índices mejor que el modelo EAV de postmeta. Si filtras por la misma clave en muchos sitios (por ejemplo stock y destacados), plantéate desnormalizar en atributos de taxonomía o en una tabla de índice. No porque sea una “buena práctica”, sino porque EXPLAIN muestra Using where; Using temporary sobre meta.
Cómo acelerar WP_Query sin paginación
Por defecto, WP_Query añade SQL_CALC_FOUND_ROWS (o un COUNT aparte en rutas más recientes) para rellenar $query->found_posts y $query->max_num_pages. Cuando construyes un widget de “relacionados”, un shortcode con tres tarjetas o un bloque de barra lateral, no necesitas paginación.
$args = [
'post_type' => 'post',
'posts_per_page' => 5,
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'fields' => 'ids',
];
$ids = ( new WP_Query( $args ) )->posts; // tablica intQué hace cada indicador:
no_found_rows => true: omite el costoso recuento de todas las coincidencias. Con más de 50k entradas, la diferencia puede ser de cientos de milisegundos por consulta.fields => 'ids': el SELECT devuelve solo los ID. Ideal cuando después llamas aget_post()de forma selectiva o construyes un mapa de ID. No lo combines con un bucle que llame athe_title()sin cargar los objetos, o tendrás un N+1.update_post_meta_cache => false: omite la carga masiva de meta en la caché de objetos para todo el resultado. Vuelve a activarlo si en el bucle lees ACF oget_post_meta.update_post_term_cache => false: lo mismo para los términos. Desactívalo cuando no muestres categorías ni etiquetas.
orderby => rand resulta tentador para “entradas similares”, pero en tablas grandes genera ORDER BY RAND(), que ordena todo el resultado en memoria. Un patrón mejor: obtén un conjunto de ID con fields => ids y no_found_rows, y luego usa array_rand en PHP, o guarda en caché la selección aleatoria unos minutos en un transient o en la caché de objetos.
meta_query lentas e índices de wp_postmeta
WordPress no crea un índice sobre (meta_key, meta_value) que sirva para filtros numéricos por valor. El índice sobre meta_key ayuda a encontrar las filas de una clave; filtrar y ordenar por meta_value suele acabar en un filesort. Síntomas en Query Monitor: mucho tiempo en el SELECT principal, Using filesort y un Rows examined alto.
Trampas habituales:
- Varias claves con el mismo significado:
_price,price,product_pricetras sucesivas migraciones. La consulta devuelve un resultado vacío o datos incoherentes. Antes de optimizar el SQL, unifica la clave. - Arrays serializados en meta:
LIKEsobre la serialización de PHP es frágil y lento. Guarda las relaciones en taxonomías o en filas meta separadas con valores planos. meta_queryconkeyvacía: históricamente permitía comparar solo el valor, pero recorre la tabla entera. Evítalo.include_children => trueen árboles de categorías profundos: WordPress expande el árbol en PHP y añade términos aIN (...). En un catálogo con cientos de categorías hijas, la lista de ID se dispara; a veces sale más barato guardar una taxonomía plana “solo hojas”.- Consulta doble en la plantilla:
new WP_Queryenfront-page.phpmás la sobrescritura de la consulta principal conquery_posts. La petición principal se ejecuta igualmente; la segunda solo añade carga. Usapre_get_postspara modificar la consulta principal.
Si un filtro meta es crítico para el negocio (disponibilidad, región, SKU), plantéate una tabla propia con los índices adecuados y una capa fina de posts_clauses / posts_join. La meta_query del core queda para filtros puntuales del panel de administración, no para la ruta caliente de la tienda.
Cómo revisar WP_Query en Query Monitor
Query Monitor es la herramienta de diagnóstico por defecto en el entorno de staging. Lo instalas, abres el panel Queries y filtras por el caller WP_Query. Busca:
- el número de consultas por petición (duplicados de la misma
meta_queryen widgets), - el tiempo de un SELECT concreto con JOIN a
wp_postmeta, - el componente (tema o plugin): a menudo la “WP_Query lenta” está en el plugin de entradas relacionadas de un tercero, no en tu shortcode,
- duplicados: el mismo SQL dos veces, porque la caché de objetos está desactivada o
wp_cache_flushse ejecuta en un bucle.
En staging, activa también SAVEQUERIES, solo mientras dure la medición. En producción, deja Query Monitor detrás de la capability view_query_monitor o desactívalo del todo, porque el propio plugin tiene un coste.
Rutina práctica antes del merge:
- Abre la página con el nuevo filtro.
- Guarda el SQL de Query Monitor.
- Pásalo por
EXPLAINen una copia de la base de datos. - Compáralo con la variante
no_found_rows+fields => ids. - Solo entonces guarda el resultado en caché.
Cómo usa WP_Query la caché de objetos
La caché de objetos (Redis, Memcached mediante un drop-in) no acelera por arte de magia un SQL malo. Acelera las lecturas repetidas de los mismos objetos de entrada, meta y término dentro de una petición y entre peticiones, siempre que los grupos de caché no se vacíen con cada escritura.
Cómo encaja con WP_Query:
- Tras el SELECT, el core llama a
update_post_caches(). Los indicadoresupdate_post_*_cachedeciden si se cargan de inmediato meta y términos con una consulta agrupada o si se deja para la carga diferida. - Con
fields => idsno hay objetosWP_Postcompletos en$query->posts, así que la caché de entradas no se llena de paso. Si justo después hacesforeach ( $ids as $id ) { get_post( $id ); get_post_meta( $id, ... ); }, vuelves al N+1, salvo que llames tú mismo aupdate_postmeta_cache( $ids ). - Un transient con una lista serializada de ID durante 5-15 minutos suele salir más barato que un “ingenioso”
orderby randen cada visita. Invalidación: el hooksave_post/edited_termdel CPT correspondiente.
No confundas la caché de página (HTML completo) con la caché de objetos. La caché de página evita PHP; la caché de objetos ayuda cuando PHP tiene que construir la respuesta de todos modos (carrito, usuario conectado, ESI). Un widget de entradas relacionadas en la ficha de producto de una tienda B2B suele tirar de la caché de objetos, no de la caché de página completa.
Entradas relacionadas en WordPress sin plugin
function wppoland_get_related_post_ids( int $post_id, int $limit = 3 ): array {
$terms = get_the_terms( $post_id, 'category' );
if ( empty( $terms ) || is_wp_error( $terms ) ) {
return [];
}
$term_ids = wp_list_pluck( $terms, 'term_id' );
$cache_key = 'related_' . $post_id . '_' . $limit;
$cached = wp_cache_get( $cache_key, 'wppoland_related' );
if ( false !== $cached ) {
return $cached;
}
$query = new WP_Query(
[
'post_type' => 'post',
'category__in' => $term_ids,
'post__not_in' => [ $post_id ],
'posts_per_page' => $limit * 4,
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'fields' => 'ids',
'ignore_sticky_posts' => true,
]
);
$pool = $query->posts;
if ( empty( $pool ) ) {
wp_cache_set( $cache_key, [], 'wppoland_related', 10 * MINUTE_IN_SECONDS );
return [];
}
shuffle( $pool );
$picked = array_slice( $pool, 0, $limit );
wp_cache_set( $cache_key, $picked, 'wppoland_related', 10 * MINUTE_IN_SECONDS );
return $picked;
}Aquí no hay ORDER BY RAND() en el SQL. Obtienes un conjunto mayor de ID con una consulta barata, lo barajas en PHP y guardas el resultado en caché. En un blog con unos pocos miles de entradas, el perfil es más estable que sortear en MySQL.
Lista de comprobación para optimizar WP_Query antes de pasar a producción
Antes de llevar una nueva WP_Query a producción:
- ¿Necesitas de verdad la paginación? Si no,
no_found_rows. - ¿Muestras meta y términos en el bucle? Si no, desactiva los dos
update_post_*_cache. - ¿Te bastan los ID? Entonces
fields => idsy una hidratación consciente. - ¿Usa
meta_queryeltypeen números y fechas? - ¿Evitas
LIKE '%…%'en los filtros más usados? - ¿Has medido la consulta en Query Monitor y con EXPLAIN?
- ¿Se puede guardar el resultado en la caché de objetos o en un transient con una invalidación sensata?
Resumen
tax_query y meta_query siguen el mismo patrón: cláusulas, relation, anidamiento. El rendimiento no sale de un “alojamiento más rápido”, sino de indicadores elegidos con criterio (no_found_rows, fields, caché de meta y términos), de evitar recorridos completos de wp_postmeta y de medir en Query Monitor. La caché de objetos potencia una buena consulta; una mala no la arregla.
Fuentes oficiales: la clase WP_Query, las secciones taxonomy parameters y custom field parameters, las clases WP_Tax_Query y WP_Meta_Query, la optimización del rendimiento y WP_Object_Cache.







