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ą.
Jak działa tax_query w WP_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.
Filtrowanie po polach własnych przez meta_query
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 LIKE w meta_query
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',
],
];Łączenie tax_query i meta_query w jednym zapytaniu
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ą drastycznie wydłużyć 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.
Jak przyspieszyć WP_Query bez paginacji
Domyślne WP_Query przy zapytaniu z LIMIT dokłada SQL_CALC_FOUND_ROWS, a potem wykonuje SELECT FOUND_ROWS(), ż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ń. ZSQL_CALC_FOUND_ROWSMySQL musi przejść wszystkie pasujące wiersze, nawet gdy zwraca pięć; bez niego może skończyć naLIMIT, gdy kolejność wynika z indeksu. MySQL oznaczyłSQL_CALC_FOUND_ROWSiFOUND_ROWS()jako przestarzałe od wersji 8.0.17 (dokumentacja MySQL).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.
Wyłączanie filtrów i narzutu hooków zewnętrznych
Gdy wtyczki zewnętrzne podpinają się pod hooki posts_where, posts_join czy posts_clauses, zapytania pomocnicze mogą dziedziczyć zbędne warunki językowe lub filtry uprawnień.
W skryptach CLI, zadaniach cron oraz mikro-zapytaniach możesz odizolować WP_Query, przekazując 'suppress_filters' => true:
$args_czyste = [
'post_type' => 'product',
'posts_per_page' => 10,
'no_found_rows' => true,
'suppress_filters' => true,
'fields' => 'ids',
];
$query_czyste = new WP_Query( $args_czyste );Funkcja get_posts() domyślnie ustawia 'suppress_filters' => true, podczas gdy new WP_Query() ma domyślnie false. To wyjaśnia częste rozbieżności w wynikach obu metod przy identycznych argumentach. suppress_filters wyłącza filtry posts_* (posts_where, posts_join, posts_clauses i pozostałe), ale nie akcję pre_get_posts, którą core wywołuje zawsze. Jeśli wtyczka wielojęzyczna zawęża język przez te filtry, zapytanie zwróci wpisy we wszystkich językach; sprawdź, jak filtruje twoja wtyczka, zanim to włączysz.
Wolne meta_query i indeksy wp_postmeta
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.
Indeksy kompozytowe i optymalizacja schematu tabeli wp_postmeta
Schemat WordPressa (wp-admin/includes/schema.php) tworzy w tabeli wp_postmeta trzy klucze: PRIMARY KEY (meta_id), KEY post_id (post_id) oraz KEY meta_key (meta_key(191)).
Rozważmy zapytanie filtrujące:
SELECT post_id FROM wp_postmeta WHERE meta_key = '_stock_status' AND meta_value = 'instock';Silnik bazy danych może użyć jedynie indeksu na meta_key. Oznacza to pobranie wskaźników do wszystkich wierszy o danym kluczu, a następnie odczytanie pełnych rekordów z dysku lub puli buforów InnoDB, aby sprawdzić warunek meta_value = 'instock'. Przy wielu wierszach na jeden klucz oznacza to wiele odczytów z puli buforów.
Indeks kompozytowy (meta_key(191), meta_value(100)) pozwala MySQL sprawdzić klucz i wartość w samym indeksie, więc z tabeli czyta tylko wiersze, które już pasują. To nie jest indeks pokrywający: indeks pomocniczy InnoDB przechowuje klucz główny (meta_id), a nie post_id, więc wiersz i tak jest odczytywany, żeby pobrać post_id:
ALTER TABLE wp_postmeta ADD INDEX meta_key_val_comp (meta_key(191), meta_value(100));Tego typu modyfikacje należy zawsze zweryfikować w środowisku stagingowym za pomocą EXPLAIN. Każdy dodatkowy indeks przyspiesza selektywne odczyty, lecz zwiększa narzut czasowy operacji zapisu (update_post_meta(), add_post_meta()).
Bezpośrednie zapytania SQL z wpdb prepare a WP_Query
WP_Query doskonale sprawdza się w pętlach szablonów, obsłudze paginacji oraz automatycznym wypełnianiu pamięci podręcznej obiektów. Jednak przy zaawansowanych agregacjach, łączeniu niestandardowych tabel czy wyciąganiu zestawień analitycznych narzut obiektowy WP_Query staje się wąskim gardłem.
W takich sytuacjach optymalnym wyborem jest surowe zapytanie przez obiekt $wpdb. Należy bezwzględnie stosować metodę $wpdb->prepare(), która parametryzuje zapytanie i chroni przed atakami typu SQL Injection:
global $wpdb;
$min_stan = 5;
$kraj = 'PL';
$sql = $wpdb->prepare(
"SELECT p.ID, pm_stock.meta_value AS stan_magazynowy
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',
$min_stan,
$kraj,
20
);
$wyniki = $wpdb->get_results( $sql );Podsumowanie doboru narzędzia:
- Wybierz
WP_Query, gdy budujesz widok szablonu, korzystasz ze standardowych funkcji pętli (have_posts(),the_post()), wymagasz paginacji lub chcesz, by pobrane obiekty trafiły doWP_Object_Cache. - Wybierz
$wpdb->prepare(), gdy realizujesz operacje wsadowe, raporty analityczne z funkcjami grupowania (COUNT(),SUM()) lub gdy zapytanie dotyczy tabel spoza standardowego schematu WordPressa.
Jak sprawdzić WP_Query w Query Monitor
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.
Jak WP_Query korzysta z object cache
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.
Jak WP_Query cache’uje wyniki zapytań od WordPress 6.1
Od WordPress 6.1 WP_Query ma własny cache wyników. Po SELECT core zapisuje listę ID razem z found_posts i max_num_pages w grupie post-queries object cache. Klucz to hash argumentów i wygenerowanego SQL (WP_Query::generate_cache_key(), oznaczone @since 6.1.0 w class-wp-query.php). Argument cache_results włącza i wyłącza ten mechanizm; domyślnie ma wartość true.
Trzy szczegóły z kodu core decydują, czy ten cache ci pomoże:
- Invalidacja jest globalna. Klucz jest solony wartością
last_changedgrupyposts, a przy zapytaniu ztax_querytakże grupyterms. Zapis dowolnego wpisu albo dodanie, zmiana lub usunięcie dowolnej wartości post meta przesuwalast_changed(core podpinawp_cache_set_posts_last_changed()podupdated_post_metai pozostałe akcje meta wdefault-filters.php). W sklepie, w którym import aktualizuje stany magazynowe co kilka minut, trafień w cache będzie mało. - Część zapytań nigdy nie trafia do cache. Zapytanie, którego
orderbyzawieraRAND(, omija cache, tak samo jak zapytanie, któremu filtryposts_fieldsalboposts_clauseszmieniły wybierane pola. - Bez trwałego object cache (Redis, Memcached) domyślny
WP_Object_Cachetrzyma dane tylko do końca bieżącego żądania. Cache wyników pomaga wtedy wyłącznie, gdy to samo zapytanie wykona się dwa razy podczas jednego ładowania strony.
To kolejny powód, żeby pobierać pulę ID i tasować ją w PHP zamiast orderby => rand: zapytanie o pulę może przyjść z post-queries, losowe nie. Jeśli wyłączysz cache_results w eksporcie albo skrypcie CLI, cache wpisów też nie zostanie wypełniony zwróconymi obiektami.
Powiązane wpisy w WordPress 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.
Checklista optymalizacji WP_Query przed wdrożeniem na produkcję
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.







