WP_Query avanzado: taxonomías, metadatos y rendimiento (2026)

WP_Query avanzado: taxonomías, metadatos y rendimiento (2026)

Última verificación: 18 de octubre de 2026
17 min de lectura
Guía
Desarrollador full-stack
500+ proyectos WP

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, cuando la consulta tiene LIMIT, WP_Query añade SQL_CALC_FOUND_ROWS y después ejecuta SELECT FOUND_ROWS() 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; // array de int

Qué hace cada indicador:

  • no_found_rows => true: omite el costoso recuento de todas las coincidencias. Con SQL_CALC_FOUND_ROWS, MySQL tiene que recorrer todas las filas que cumplen el filtro aunque devuelva solo cinco; sin él puede detenerse en el LIMIT cuando el orden sale de un índice. MySQL declara obsoletos SQL_CALC_FOUND_ROWS y FOUND_ROWS() desde la versión 8.0.17 (manual de MySQL).
  • fields => 'ids': el SELECT devuelve solo los ID. Ideal cuando después llamas a get_post() de forma selectiva o construyes un mapa de ID. No lo combines con un bucle que llame a the_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 o get_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.

#Supresión de filtros y control de hooks secundarios

Cuando plugins de terceros se enganchan a posts_where, posts_join o posts_clauses, las consultas secundarias heredan condiciones innecesarias o restricciones de multilenguaje.

En tareas de fondo, scripts CLI o micro-consultas aisladas, puedes proteger WP_Query usando 'suppress_filters' => true:

$args_limpios = [
    'post_type'        => 'product',
    'posts_per_page'   => 10,
    'no_found_rows'    => true,
    'suppress_filters' => true,
    'fields'           => 'ids',
];
$query_limpia = new WP_Query( $args_limpios );

Ten en cuenta que get_posts() activa 'suppress_filters' => true de forma predeterminada, mientras que new WP_Query() lo mantiene en false. suppress_filters desactiva los filtros posts_* (posts_where, posts_join, posts_clauses y similares), pero no la acción pre_get_posts, que el core ejecuta siempre. Si un plugin multilingüe limita el idioma mediante esos filtros, la consulta devolverá entradas de todos los idiomas; comprueba cómo filtra tu plugin antes de activarlo.

#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:

  1. Varias claves con el mismo significado: _price, price, product_price tras sucesivas migraciones. La consulta devuelve un resultado vacío o datos incoherentes. Antes de optimizar el SQL, unifica la clave.
  2. Arrays serializados en meta: LIKE sobre la serialización de PHP es frágil y lento. Guarda las relaciones en taxonomías o en filas meta separadas con valores planos.
  3. meta_query con key vacía: históricamente permitía comparar solo el valor, pero recorre la tabla entera. Evítalo.
  4. include_children => true en árboles de categorías profundos: WordPress expande el árbol en PHP y añade términos a IN (...). 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”.
  5. Consulta doble en la plantilla: new WP_Query en front-page.php más la sobrescritura de la consulta principal con query_posts. La petición principal se ejecuta igualmente; la segunda solo añade carga. Usa pre_get_posts para 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.

#Índices compuestos y optimización del esquema en wp_postmeta

El esquema de WordPress (wp-admin/includes/schema.php) crea en wp_postmeta tres índices: PRIMARY KEY (meta_id), KEY post_id (post_id) y KEY meta_key (meta_key(191)).

Cuando ejecutas una comprobación de stock:

SELECT post_id FROM wp_postmeta WHERE meta_key = '_stock_status' AND meta_value = 'instock';

MySQL únicamente puede apoyarse en el índice de meta_key. Localiza las filas con esa clave y luego debe examinar cada registro completo para evaluar si meta_value coincide. Con muchas filas por clave, eso son muchas lecturas del buffer pool.

Un índice compuesto (meta_key(191), meta_value(100)) permite a MySQL comprobar clave y valor dentro del índice, de modo que solo lee de la tabla las filas que ya coinciden. No es un índice de cobertura: el índice secundario de InnoDB guarda la clave primaria (meta_id), no post_id, así que la fila se sigue leyendo para obtener post_id:

ALTER TABLE wp_postmeta ADD INDEX meta_key_val_comp (meta_key(191), meta_value(100));

Comprueba siempre estos cambios en staging con EXPLAIN y Query Monitor. Los índices adicionales aceleran las lecturas complejas, pero añaden sobrecarga en las operaciones de escritura de update_post_meta().

#Consultas directas con wpdb prepare frente a WP_Query

WP_Query es excelente para gestionar el ciclo de vida de temas, paginación e integración con la caché de objetos. Sin embargo, para agregaciones analíticas, cruces con tablas personalizadas o recuperación masiva de identificadores sin la sobrecarga del núcleo, una consulta SQL directa con $wpdb resulta mucho más rápida.

Emplea siempre $wpdb->prepare() para parametrizar la consulta y prevenir inyecciones SQL:

global $wpdb;

$stock_minimo = 5;
$codigo_pais  = 'ES';

$sql = $wpdb->prepare(
    "SELECT p.ID, pm_stock.meta_value AS stock_actual
     FROM {$wpdb->posts} p
     INNER JOIN {$wpdb->postmeta} pm_stock 
        ON p.ID = pm_stock.post_id AND pm_stock.meta_key = %s
     INNER JOIN {$wpdb->postmeta} pm_geo 
        ON p.ID = pm_geo.post_id AND pm_geo.meta_key = %s
     WHERE p.post_type = %s
       AND p.post_status = %s
       AND CAST(pm_stock.meta_value AS UNSIGNED) >= %d
       AND pm_geo.meta_value = %s
     ORDER BY CAST(pm_stock.meta_value AS UNSIGNED) DESC
     LIMIT %d",
    '_stock',
    '_target_country',
    'product',
    'publish',
    $stock_minimo,
    $codigo_pais,
    20
);

$resultados = $wpdb->get_results( $sql );

Criterio de elección:

  • Usa WP_Query en plantillas de temas, archivos paginados estándar y bucles que requieran hidratar la caché de objetos nativa.
  • Usa $wpdb->prepare() para exportaciones, paneles estadísticos, cálculos numéricos agregados (SUM(), AVG()) o micro-servicios donde la cascada de filtros de WP_Query añada latencia innecesaria.

#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_query en 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_flush se 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:

  1. Abre la página con el nuevo filtro.
  2. Guarda el SQL de Query Monitor.
  3. Pásalo por EXPLAIN en una copia de la base de datos.
  4. Compáralo con la variante no_found_rows + fields => ids.
  5. 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 indicadores update_post_*_cache deciden si se cargan de inmediato meta y términos con una consulta agrupada o si se deja para la carga diferida.
  • Con fields => ids no hay objetos WP_Post completos en $query->posts, así que la caché de entradas no se llena de paso. Si justo después haces foreach ( $ids as $id ) { get_post( $id ); get_post_meta( $id, ... ); }, vuelves al N+1, salvo que llames tú mismo a update_postmeta_cache( $ids ).
  • Un transient con una lista serializada de ID durante 5-15 minutos suele salir más barato que un “ingenioso” orderby rand en cada visita. Invalidación: el hook save_post / edited_term del 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.

#Cómo guarda WP_Query en caché los resultados desde WordPress 6.1

Desde WordPress 6.1, WP_Query tiene su propia caché de resultados. Tras el SELECT, el core guarda la lista de ID junto con found_posts y max_num_pages en el grupo post-queries de la caché de objetos. La clave es un hash de los argumentos y del SQL generado (WP_Query::generate_cache_key(), marcado @since 6.1.0 en class-wp-query.php). El argumento cache_results lo activa o lo desactiva; por defecto vale true.

Tres detalles del código del core deciden si esta caché te sirve:

  • La invalidación es global. La clave lleva como sal el valor last_changed del grupo posts, y también el de terms cuando la consulta tiene tax_query. Guardar cualquier entrada, o añadir, actualizar o borrar cualquier valor de post meta, cambia last_changed (el core engancha wp_cache_set_posts_last_changed() a updated_post_meta y a las demás acciones de meta en default-filters.php). En una tienda donde una importación actualiza el stock cada pocos minutos, la tasa de aciertos se queda baja.
  • Algunas consultas nunca se guardan. Si el orderby contiene RAND(, la consulta se salta la caché, y lo mismo ocurre cuando los filtros posts_fields o posts_clauses cambian los campos seleccionados.
  • Sin una caché de objetos persistente (Redis, Memcached), el WP_Object_Cache por defecto solo guarda datos durante la petición actual. La caché de resultados ayuda entonces únicamente cuando la misma consulta se ejecuta dos veces en una carga de página.

Es otro motivo para obtener un conjunto de ID y barajarlo en PHP en lugar de usar orderby => rand: la consulta del conjunto puede servirse desde post-queries y la aleatoria no. Si desactivas cache_results en una exportación o un script CLI, tampoco se rellena la caché de entradas con los objetos devueltos.

#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:

  1. ¿Necesitas de verdad la paginación? Si no, no_found_rows.
  2. ¿Muestras meta y términos en el bucle? Si no, desactiva los dos update_post_*_cache.
  3. ¿Te bastan los ID? Entonces fields => ids y una hidratación consciente.
  4. ¿Usa meta_query el type en números y fechas?
  5. ¿Evitas LIKE '%…%' en los filtros más usados?
  6. ¿Has medido la consulta en Query Monitor y con EXPLAIN?
  7. ¿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.

Siguiente paso

Transforma el artículo en una implementación real

Este bloque refuerza el enlazado interno y lleva al lector al siguiente paso más útil dentro de la arquitectura del sitio.

¿Quieres implementar esto en tu sitio?

Si el problema está en los Core Web Vitals, en el rendering lento o en el peso de WordPress, puedo mapear e implementar la optimización.

Explora otros servicios WordPress y base de conocimiento

Refuerza tu negocio con soporte técnico profesional en áreas clave del ecosistema WordPress.

Preguntas frecuentes

Respuestas prácticas para aplicar el tema en la ejecución real.

¿Cuándo conviene usar no_found_rows => true en WP_Query?#
Siempre que no se necesite paginación, como en widgets de barra lateral, artículos relacionados, carruseles o endpoints de REST API con límite fijo. Evita el cálculo de SQL_CALC_FOUND_ROWS, lo que reduce notablemente el tiempo de consulta en MySQL.
¿Por qué las consultas con meta_query suelen ser lentas?#
Porque la tabla wp_postmeta no está diseñada para filtrado relacional. La columna meta_value es de tipo LONGTEXT y no se indexa de forma óptima, obligando a realizar un escaneo completo de filas en comparaciones con LIKE o sin índices.
¿Por qué pre_get_posts es mejor que instanciar un nuevo WP_Query en la plantilla?#
Porque WordPress ya ejecuta la consulta principal antes de cargar la plantilla. Crear un nuevo WP_Query duplica la carga en la base de datos, desperdicia recursos del servidor y con frecuencia rompe la paginación nativa y las URLs canónicas.

¿Necesitas un FAQ adaptado a tu sector y mercado? Preparo una versión alineada con tus objetivos de negocio.

Hablemos

Artículos Relacionados