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 intCo 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 robiszget_post()selektywnie albo budujesz mapę ID. Nie łącz tego z pętlą, która wołathe_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:
- Wiele kluczy o tej samej semantyce -
_price,price,product_pricepo migracjach. Zapytanie trafia w pusty wynik albo w niespójne dane. Zanim optymalizujesz SQL, ujednolić klucz. - Serializowane tablice w meta -
LIKEna serializacji PHP jest kruche i wolne. Relacje trzymaj w taksonomiach albo w osobnych wierszach meta z płaskimi wartościami. meta_queryz pustymkey- historycznie pozwalało na porównanie samej wartości, ale generuje skan całej tabeli. Unikaj.include_children => truena głębokich drzewach kategorii - WordPress rozwija drzewo w PHP i dokłada termy doIN (...). Na katalogu z setkami kategorii dziecięcych lista ID puchnie; czasem taniej jest zapisać płaską taksonomię „leaf only”.- Podwójne zapytanie w szablonie -
new WP_Querywfront-page.phpplus nadpisanie głównego query przezquery_posts. Główny request i tak idzie; drugi tylko dokłada obciążenie. Używajpre_get_postsdo 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_queryw 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_flushleci 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:
- Otwórz stronę z nowym filtrem.
- Zapisz SQL z Query Monitor.
- Wrzuć do
EXPLAINna kopii bazy. - Porównaj z wariantem
no_found_rows+fields => ids. - 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(). Flagiupdate_post_*_cachesterują, czy od razu zaciągnąć meta i termy jednym zapytaniem zbiorczym, czy zostawić lazy load. - Przy
fields => idsnie ma pełnych obiektówWP_Postw$query->posts- cache postów nie zapełnia się „przy okazji”. Jeśli zaraz potem robiszforeach ( $ids as $id ) { get_post( $id ); get_post_meta( $id, ... ); }, wracasz do N+1, chyba że ręcznie wywołaszupdate_postmeta_cache( $ids ). - Transient z serializowaną listą ID na 5-15 minut jest często tańszy niż „sprytny”
orderby randna każdym hitcie. Invalidacja: hooksave_post/edited_termdla 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ę:
- Czy paginacja jest realnie potrzebna? Jeśli nie -
no_found_rows. - Czy renderujesz meta i termy w pętli? Jeśli nie - wyłącz oba
update_post_*_cache. - Czy wystarczą ID? -
fields => ids+ świadomy hydrate. - Czy
meta_queryużywatypeprzy liczbach i datach? - Czy unikasz
LIKE '%…%'na gorących filtrach? - Czy zmierzyłeś zapytanie w Query Monitor i EXPLAIN?
- 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.







