Zaawansowane WP_Query: taksonomie, meta data i wydajność (2026)

Zaawansowane WP_Query: taksonomie, meta data i wydajność (2026)

Ostatnio zweryfikowano: 21 września 2026
11 min czytania
Przewodnik
Full-stack developer
500+ projektów WP

Każdy programista WordPress umie odpalić prostą pętlę. Problem zaczyna się, gdy klient prosi o filtr złożony: produkty z kolekcji sezonowej w danej kategorii kolorów, bez pozycji oznaczonych jako niedostępne, posortowane po polu własnym. Wtedy cat=5 i query_posts() kończą się w debug.log albo na produkcji pod obciążeniem.

Ten przewodnik jest praktyczny. Składamy tax_query i meta_query, ustawiamy relation AND/OR, włączamy no_found_rows i fields => ids, omijamy pułapki indeksów w wp_postmeta, a na końcu sprawdzamy zapytanie w Query Monitor i object cache. Kod jest pod WordPress 6.x; wzorce działają też na starszych 6.0+, o ile nie mieszasz legacy-parametrów z nową składnią tablicową.

#Anatomia tax_query

Zapomnij o cat, tag_id i category__and jako domyślnym API. Te skróty nadal działają, ale nie skalują się do zagnieżdżonej logiki. tax_query jest standardem: każda klauzula to tablica z taxonomy, field, terms, opcjonalnie operator i include_children.

#Relacja AND między taksonomiami

Chcesz CPT movie, które są jednocześnie w gatunku action i w roku 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 );

relation na najwyższym poziomie dotyczy tylko bezpośrednich dzieci. Bez jawnego 'relation' => 'AND' WordPress i tak zakłada AND - warto to jednak pisać wprost, bo przy code review widać intencję.

#Relacja OR w jednej taksonomii

Gdy lista terminów ma działać jako „dowolny z”, wystarczy jedna klauzula z tablicą terms i domyślnym operator IN. Osobne klauzule z relation => OR są potrzebne, gdy łączysz różne taksonomie albo różne operatory (IN vs 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',
        ],
    ],
];

#Zagnieżdżanie grup (AND wokół OR)

Prawdziwy filtr katalogowy zwykle wygląda tak: (kategoria A OR kategoria B) AND (kolor C) AND NOT status wycofany. W tax_query robisz to zagnieżdżoną tablicą z własnym 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',
        ],
    ],
];

Każda zagnieżdżona grupa generuje osobny JOIN do wp_term_relationships. Trzy poziomy zagnieżdżenia na katalogu z setkami tysięcy wierszy to już sygnał, żeby zmierzyć EXPLAIN - nie żeby „uprościć marketingowo”, tylko żeby wiedzieć, czy MySQL wybiera indeksy.

#meta_query: typy, EXISTS i porównania

meta_query filtruje po wp_postmeta. Każda klauzula ma key, opcjonalnie value, compare i type. type (NUMERIC, DECIMAL, DATE, CHAR, BINARY) wpływa na CAST w SQL. Bez NUMERIC porównanie „10” i „2” kończy się leksykograficznie: '2' > '10' w CHAR.

#Podstawowa klauzula z CAST

$args = [
    'post_type'  => 'product',
    'meta_query' => [
        [
            'key'     => '_stock',
            'value'   => 0,
            'compare' => '>',
            'type'    => 'NUMERIC',
        ],
    ],
];

#EXISTS i NOT EXISTS

Czasem nie interesuje Cię wartość, tylko obecność klucza. compare => 'EXISTS' (i NOT EXISTS) nie wymaga value. To tańsze niż LIKE na pustym stringu i czytelniejsze w code review.

$args = [
    'post_type'  => 'event',
    'meta_query' => [
        'relation' => 'AND',
        [
            'key'     => 'event_start',
            'compare' => 'EXISTS',
        ],
        [
            'key'     => 'event_cancelled',
            'compare' => 'NOT EXISTS',
        ],
    ],
];

#BETWEEN, IN i pułapka LIKE

BETWEEN wymaga tablicy dwóch wartości. IN - listy. LIKE z % po obu stronach wymusza full scan po meta_value, bo indeks na meta_key nie wystarczy do wyszukiwania po środku wartości. Jeśli filtrujesz po fragmencie tekstu w meta, rozważ osobną taksonomię albo zdenormalizowaną kolumnę w tabeli własnej - LIKE '%foo%' na milionie wierszy wp_postmeta to klasyczny hotspot z trac.wordpress.org i ticketów wydajnościowych core.

#Relacja AND/OR w meta_query

Składnia jest lustrzana względem tax_query. Możesz zagnieżdżać grupy. Nazwane klauzule (klucz stringowy zamiast indeksu numerycznego) pozwalają potem sortować przez orderby wskazujący na nazwę klauzuli - to oficjalny wzorzec z dokumentacji WP_Query, nie hack.

$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',
    ],
];

#Składanie tax_query z meta_query

Oba argumenty działają jednocześnie. WordPress łączy je w jednym SELECT z JOIN-ami do termów i meta. Typowy scenariusz katalogu: taksonomia kategorii plus pole _featured lub zakres dat.

$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' => '=',
        ],
    ],
];

Uwaga praktyczna: każdy dodatkowy JOIN meta to kolejny skan wp_postmeta. Dwa filtry meta na dużym sklepie WooCommerce potrafią zdominować czas odpowiedzi szybciej niż trzy taksonomie, bo term_relationships ma lepszy kształt indeksów niż EAV w postmeta. Jeśli filtrujesz po tym samym kluczu w wielu miejscach (np. stock i featured), rozważ denormalizację do atrybutów taksonomicznych albo tabeli indeksującej - nie dlatego, że „best practice”, tylko dlatego, że EXPLAIN pokazuje Using where; Using temporary na meta.

#Flagi wydajności: no_found_rows i fields => ids

Domyślne WP_Query dokłada SQL_CALC_FOUND_ROWS (albo osobne COUNT w nowszych ścieżkach), żeby wypełnić $query->found_posts i $query->max_num_pages. Gdy budujesz widget „powiązane”, shortcode z trzema kartami albo blok w sidebarze, paginacja nie jest potrzebna.

$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

Co robi każda flaga:

  • no_found_rows => true - pomija kosztowne liczenie wszystkich dopasowań. Na zbiorze 50k+ postów różnica bywa rzędu setek milisekund na jedno zapytanie.
  • fields => 'ids' - SELECT zwraca tylko ID. Idealne, gdy potem i tak robisz get_post() selektywnie albo budujesz mapę ID. Nie łącz tego z pętlą, która woła the_title() bez załadowania obiektów - dostaniesz N+1.
  • update_post_meta_cache => false - pomija bulk-load meta do object cache dla całego wyniku. Włączaj z powrotem, jeśli w pętli czytasz ACF / get_post_meta.
  • update_post_term_cache => false - to samo dla terminów. Wyłącz, gdy nie renderujesz kategorii i tagów.

orderby => rand wygląda kusząco w „podobnych wpisach”, ale na dużych tabelach generuje ORDER BY RAND(), które sortuje cały wynik w pamięci. Lepszy wzorzec: pobierz pulę ID z fields => ids i no_found_rows, potem array_rand po stronie PHP, albo cache’uj wylosowany zestaw na kilka minut w transient / object cache.

#Pułapki indeksowania w bazie

WordPress nie tworzy indeksu na (meta_key, meta_value) w sposób użyteczny dla filtrów numerycznych po wartości. Indeks na meta_key pomaga znaleźć wiersze danego klucza; filtrowanie i sortowanie po meta_value często kończy się filesort. Objawy w Query Monitor: długi czas na głównym SELECT, Using filesort, wysoki Rows examined.

Typowe pułapki:

  1. Wiele kluczy o tej samej semantyce - _price, price, product_price po migracjach. Zapytanie trafia w pusty wynik albo w niespójne dane. Zanim optymalizujesz SQL, ujednolić klucz.
  2. Serializowane tablice w meta - LIKE na serializacji PHP jest kruche i wolne. Relacje trzymaj w taksonomiach albo w osobnych wierszach meta z płaskimi wartościami.
  3. meta_query z pustym key - historycznie pozwalało na porównanie samej wartości, ale generuje skan całej tabeli. Unikaj.
  4. include_children => true na głębokich drzewach kategorii - WordPress rozwija drzewo w PHP i dokłada termy do IN (...). Na katalogu z setkami kategorii dziecięcych lista ID puchnie; czasem taniej jest zapisać płaską taksonomię „leaf only”.
  5. Podwójne zapytanie w szablonie - new WP_Query w front-page.php plus nadpisanie głównego query przez query_posts. Główny request i tak idzie; drugi tylko dokłada obciążenie. Używaj pre_get_posts do modyfikacji głównego zapytania.

Jeśli filtr meta jest krytyczny dla biznesu (availability, region, SKU), rozważ tabelę własną z właściwymi indeksami i cienką warstwą posts_clauses / posts_join - core meta_query zostaje do rzadkich filtrów admina, nie do hot path storefrontu.

#Query Monitor: jak czytać wynik

Query Monitor to domyślne narzędzie diagnostyczne w środowisku staging. Instalujesz, otwierasz panel Queries, filtrujesz po callerze WP_Query. Szukasz:

  • liczby zapytań na request (duplikaty tej samej meta_query w widgetach),
  • czasu pojedynczego SELECT z JOIN-ami do wp_postmeta,
  • komponentu (motyw vs wtyczka) - często „wolny WP_Query” siedzi w related posts trzeciej wtyczki, nie w Twoim shortcode,
  • duplikatów: ten sam SQL dwa razy, bo object cache jest wyłączony albo wp_cache_flush leci w pętli.

Na stagingu włącz też SAVEQUERIES tylko na czas pomiaru. Na produkcji zostaw Query Monitor za capability view_query_monitor albo całkowicie wyłącz - sam plugin ma koszt.

Praktyczny rytuał przed merge:

  1. Otwórz stronę z nowym filtrem.
  2. Zapisz SQL z Query Monitor.
  3. Wrzuć do EXPLAIN na kopii bazy.
  4. Porównaj z wariantem no_found_rows + fields => ids.
  5. Dopiero potem cache’uj wynik.

#Object cache i cache meta/term

Object cache (Redis, Memcached przez drop-in) nie przyspiesza magicznie złego SQL. Przyspiesza powtórne odczyty tych samych obiektów post/meta/term w ramach requestu i między requestami, o ile groupy cache nie są flushowane przy każdym zapisie.

Jak to łączy się z WP_Query:

  • Po SELECT core woła update_post_caches(). Flagi update_post_*_cache sterują, czy od razu zaciągnąć meta i termy jednym zapytaniem zbiorczym, czy zostawić lazy load.
  • Przy fields => ids nie ma pełnych obiektów WP_Post w $query->posts - cache postów nie zapełnia się „przy okazji”. Jeśli zaraz potem robisz foreach ( $ids as $id ) { get_post( $id ); get_post_meta( $id, ... ); }, wracasz do N+1, chyba że ręcznie wywołasz update_postmeta_cache( $ids ).
  • Transient z serializowaną listą ID na 5-15 minut jest często tańszy niż „sprytny” orderby rand na każdym hitcie. Invalidacja: hook save_post / edited_term dla danego CPT.

Nie myl page cache (pełny HTML) z object cache. Page cache omija PHP; object cache pomaga, gdy PHP i tak musi zbudować odpowiedź (koszyk, zalogowany użytkownik, ESI). Widget related posts na stronie produktu w sklepie B2B zwykle trafia w object cache, nie w full-page cache.

#Przykład: powiązane wpisy bez wtyczki

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;
}

Tu nie ma ORDER BY RAND() w SQL. Pobierasz większą pulę ID tanim zapytaniem, tasujesz w PHP, cache’ujesz wynik. Na blogu z kilkoma tysiącami wpisów to stabilniejszy profil niż losowanie w MySQL.

#Checklist przed wdrożeniem

Zanim wkleisz nowy WP_Query na produkcję:

  1. Czy paginacja jest realnie potrzebna? Jeśli nie - no_found_rows.
  2. Czy renderujesz meta i termy w pętli? Jeśli nie - wyłącz oba update_post_*_cache.
  3. Czy wystarczą ID? - fields => ids + świadomy hydrate.
  4. Czy meta_query używa type przy liczbach i datach?
  5. Czy unikasz LIKE '%…%' na gorących filtrach?
  6. Czy zmierzyłeś zapytanie w Query Monitor i EXPLAIN?
  7. Czy wynik da się cache’ować w object cache / transient z sensowną invalidacją?

#Podsumowanie

tax_query i meta_query to ten sam wzorzec: klauzule, relation, zagnieżdżanie. Wydajność nie bierze się z „szybszego hostingu”, tylko z świadomych flag (no_found_rows, fields, cache meta/term), z unikania pełnych skanów wp_postmeta i z pomiaru w Query Monitor. Object cache wzmacnia dobre zapytanie; złego nie naprawi.

Oficjalne źródła: klasa WP_Query, sekcje taxonomy parameters i custom field parameters, klasy WP_Tax_Query i WP_Meta_Query, optymalizacja wydajności oraz WP_Object_Cache.

Następny krok

Przekuj artykuł w realne wdrożenie

Pod tym wpisem dokładam linki, które domykają intencję użytkownika i prowadzą dalej w strukturze serwisu.

Chcesz wdrożyć ten temat na swojej stronie?

Jeśli problemem są Core Web Vitals, wolny frontend albo ciężki WordPress, rozpiszę i wdrożę konkretny plan optymalizacji.

Powiązany klaster

Sprawdź inne usługi WordPress i bazę wiedzy

Wzmocnij swój biznes dzięki profesjonalnemu wsparciu technicznemu w kluczowych obszarach ekosystemu WordPress.

Polecane artykuły