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

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

Última verificación: 21 de septiembre de 2026
13 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, 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 int

Qué 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 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.

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

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

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

Cluster relacionado

Explora otros servicios WordPress y base de conocimiento

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

Artículos Relacionados