WP_Query ist die Klasse, mit der WordPress Beiträge aus der Datenbank holt. Jede Archivseite, jedes Suchergebnis und jede Einzelseite startet mit einer Hauptabfrage. Sekundäre Loops sind zusätzliche WP_Query-Instanzen in Templates, Widgets oder Shortcodes. Der typische Fehler ist immer derselbe: zu viele Abfragen, zu breite Argumente oder die falsche Paginierungsvariable.
Auf deutschen Managed-Hostings (Raidboxes, All-Inkl, Host Europe, Mittwald) und eigenen VPS mit nginx + PHP-FPM merken Sie das zuerst als steigenden PHP-Worker-Druck und schlechtere TTFB-Werte, nicht als «langsame SQL» im Backend. Dieser Guide deckt Hauptabfrage versus sekundäres WP_Query, pre_get_posts, Performance-Flags, tax_query-Fallen, paged versus page, wp_reset_postdata(), Query Monitor, Object Cache und wann get_posts() reicht.
Taxonomie- und Meta-Query-Design, zusammengesetzte Indizes und der in WordPress 6.1 eingeführte Query-Cache haben einen eigenen Artikel: fortgeschrittenes WP_Query, Taxonomien, Meta und Performance.
Offizielle Referenzen: WP_Query, pre_get_posts, Performance-Optimierung und der Quellcode von class-wp-query.php.
Wie WP_Query eine Anfrage aufbaut und ausführt
Wer WP_Query beherrschen will, muss wissen, was beim Instanziieren passiert. WordPress durchläuft dabei vier Schritte:
- Argumente parsen: Ihr Argument-Array wird in ein standardisiertes Format überführt.
- SQL erzeugen: Daraus entsteht ein komplexes SQL-
SELECT. - Abfrage ausführen: Die Anfrage geht an die Datenbank.
- Objekt befüllen: Das
WP_Query-Objekt wird mit Post-Objekten und Metadaten gefüllt.
Eigenschaften des WP_Query-Objekts
$query = new WP_Query( $args ) liefert nicht einfach ein Array von Beiträgen, sondern ein umfangreiches Objekt mit diesen wichtigen Eigenschaften:
$query->posts: ein Array vonWP_Post-Objekten.$query->post_count: Anzahl der Beiträge, die auf der aktuellen Seite angezeigt werden.$query->found_posts: Gesamtzahl der Treffer, unabhängig von der Paginierung.$query->max_num_pages: Gesamtzahl der Ergebnisseiten.$query->query_vars: die Argumente, mit denen WordPress die Abfrage tatsächlich ausgeführt hat.
Hauptabfrage versus sekundäres WP_Query
Die Hauptabfrage ($wp_the_query / das globale $wp_query) wird gebaut, bevor das Template lädt. Sie entscheidet, welches Template WordPress wählt (is_home(), is_archive(), is_singular() usw.) und welche Beiträge der Standard-Loop mit have_posts() / the_post() zeigt.
if ( have_posts() ) :
while ( have_posts() ) :
the_post();
get_template_part( 'template-parts/content', get_post_type() );
endwhile;
endif;Ein sekundärer Loop ist eine neue Instanz. Er verändert die Hauptabfrage nicht und ist nicht dazu da, Archivfilter «nachträglich» zu reparieren.
$related = new WP_Query(
[
'post_type' => 'post',
'posts_per_page' => 4,
'post__not_in' => [ get_the_ID() ],
'ignore_sticky_posts' => true,
'no_found_rows' => true,
]
);
if ( $related->have_posts() ) {
while ( $related->have_posts() ) {
$related->the_post();
the_title( '<h3>', '</h3>' );
}
wp_reset_postdata();
}Nutzen Sie niemals query_posts(). Die Funktion überschreibt die Hauptabfrage mitten im Request, zerstört Paginierung und Conditional Tags und erzwingt einen Extra-Datenbankhit. Die Funktionsreferenz sagt ausdrücklich, dass sie nicht für Plugins oder Themes gedacht ist. Wenn Sie ändern wollen, was ein Archiv zeigt, nutzen Sie pre_get_posts. Wenn Sie zusätzlichen Content neben der Hauptliste brauchen, nutzen Sie new WP_Query().
In Agenturprojekten mit Page Buildern (Elementor, Breakdance) sehen Sie oft drei bis fünf «Related»-Queries pro Single-Post. Jede ohne no_found_rows und ohne Limit frisst Worker-Zeit. Auf einem WooCommerce-Shop mit 8 000 Produkten und deutschen Versandzonen spüren Sie das als Checkout-Timeout, nicht als schönen Slow-Query-Log.
Die Hauptabfrage mit pre_get_posts ändern
pre_get_posts läuft, bevor SQL gebaut wird. Sie bekommen einen Datenbankhit statt «alles holen, dann in PHP filtern» oder «eine zweite WP_Query darüberlegen».
add_action(
'pre_get_posts',
static function ( WP_Query $query ): void {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( $query->is_home() ) {
$query->set( 'posts_per_page', 12 );
$query->set( 'category__not_in', [ 17 ] ); // z. B. Newsletter-Archiv.
}
if ( $query->is_post_type_archive( 'case' ) ) {
$query->set( 'orderby', 'menu_order title' );
$query->set( 'order', 'ASC' );
}
}
);Fünf Regeln aus der Referenz und aus dem Core, die Debugging-Stunden sparen:
- Rufen Sie Conditionals als Methoden auf dem übergebenen Objekt auf (
$query->is_home()), nicht als globale Funktionen. Das globaleis_main_query()liest$wp_query, nicht die gerade gefilterte Abfrage. Im Core istWP_Query::is_main_query()ein einziger Vergleich:$wp_the_query === $this. - Guard mit
is_admin()und$query->is_main_query(). Ohne beide treffen Sie REST, Feeds, Admin-Listen und jeden sekundären Loop. is_front_page()funktioniert im Hook nicht, weil die Abfrage noch nicht eingerichtet ist;is_home()funktioniert. Für eine statische Startseite prüfen Sie stattdessen die Query-Vars.- Setzen Sie Argumente mit
$query->set(), ersetzen Sie nicht das ganze Objekt. - Rufen Sie in der Callback-Funktion keine
get_posts()odernew WP_Query()ohne harte Begründung auf. Das erzeugt neue Queries während der Query, die Sie gerade justieren.
Auf WooCommerce-Archiven ist die Hauptabfrage die Produktliste. Filtern Sie dort, nicht mit einem Extra-Loop in archive-product.php, wenn URL und Paginierung gleich bleiben sollen. Feeds (is_feed()) und Suche (is_search()) sind ebenfalls Hauptabfragen. Wenn Sie post_type auf der Startseite einschränken, prüfen Sie, dass Sie RSS und Suchergebnisse nicht leer räumen.
Ein Praxisbeispiel aus DACH-Shops: Produktarchive nach product_cat und Lagerstatus filtern, aber Newsletter-CPT und Event-CPT aus der Blog-Homepage heraushalten. Das gehört in pre_get_posts, nicht in drei Template-Queries mit post__not_in.
Was the_post() und have_posts() hinter dem Loop tun
Ein Blick in class-wp-query.php erklärt mehrere Bugs, die in Templates zufällig wirken.
- Beim ersten Aufruf von
the_post()primt der Core die Autoren-Caches für die gesamte Ergebnismenge und feuert die Actionloop_start. Hat die Abfrage keine vollständigen Post-Objekte geliefert, primt er an dieser Stelle auch Post-, Meta- und Term-Caches (außerupdate_post_meta_cacheoderupdate_post_term_cachesteht auffalse). - Mit
'fields' => 'ids'macht der aktuelle Core aus jeder ID inthe_post()perget_post()ein volles Post-Objekt. Der Loop funktioniert also, aber die ID-Liste kostet dann genauso viel wie eine volle Abfrage. setup_postdata()setzt mehr Globals als$post:$id,$authordata,$currentday,$currentmonth,$page,$pages,$multipage,$moreund$numpages, danach feuert es die Actionthe_post. Ein sekundärer Loop ohne Reset lässt alle davon auf den letzten Related-Beitrag zeigen. Deshalb bricht eine Autorenbox oder ein mehrseitiger Beitrag (<!--nextpage-->) nach einem Related-Posts-Block.- Gehen
have_posts()die Beiträge aus, feuert esloop_endund spult die Abfrage zurück. Hat die Abfrage nichts gefunden, feuert es stattdessenloop_no_results.
WP_Query beschleunigen mit no_found_rows und fields
Standard-WP_Query berechnet auch die Gesamtzahl der Treffer für Paginierung (SQL_CALC_FOUND_ROWS im SELECT, danach SELECT FOUND_ROWS()). Brauchen Sie keine Seitenzahlen, schalten Sie das ab:
$ids = new WP_Query(
[
'post_type' => 'post',
'posts_per_page' => 20,
'fields' => 'ids',
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
]
);
foreach ( $ids->posts as $post_id ) {
// Nur IDs - kein volles WP_Post, kein Meta/Term-Prime.
}fields => 'ids' liefert Integer-IDs statt voller Post-Objekte. Ideal für Relationen, Exclude-Listen oder wenn Sie danach gezielt get_post() / Meta nur für sichtbare Karten laden. update_post_meta_cache und update_post_term_cache auf false verhindern Batch-Primes, die Sie nicht brauchen.
posts_per_page => -1 bleibt verboten. Auch «nur 200 Case Studies» ohne Limit werden irgendwann 2 000. Setzen Sie immer eine Obergrenze und paginieren oder cachen Sie.
Wenn Sie Paginierung brauchen, lassen Sie no_found_rows weg (oder setzen Sie es explizit auf false). Sonst stimmt max_num_pages nicht und die Navigation bricht still.
tax_query richtig bauen
tax_query ist mächtig und teuer, wenn Relation und Operatoren unklar sind. Ein häufiger Fehler: mehrere Taxonomien mit relation => AND und operator => IN, obwohl OR gemeint war - oder umgekehrt.
$events = new WP_Query(
[
'post_type' => 'event',
'posts_per_page' => 10,
'no_found_rows' => true,
'tax_query' => [
'relation' => 'AND',
[
'taxonomy' => 'event_city',
'field' => 'slug',
'terms' => [ 'berlin', 'hamburg', 'muenchen' ],
],
[
'taxonomy' => 'event_type',
'field' => 'slug',
'terms' => [ 'workshop' ],
],
],
]
);Regeln aus der Praxis:
- Bevorzugen Sie Taxonomien gegenüber
meta_queryfür filterbare Attribute (Stadt, Branche, Produktlinie). Meta-Joins skalieren schlechter. - Nutzen Sie
field => 'term_id', wenn Sie IDs haben. Slugs erzeugen Extra-Lookups. include_children => falseauf hierarchischen Taxonomien, wenn Sie keine Kind-Terms wollen - sonst holt WordPress die ganze Unterbaum-Logik mit.- Kombinieren Sie
tax_querynicht mit unnötigemmeta_query«zur Sicherheit». Jeder Join kostet.
Bei deutschen Veranstaltungs- und Verbandsseiten (WordCamps, Meetup-Serien, Branchenkalender) ist event_city + event_type der klassische Fall. Ein meta_query auf _event_city String war der alte Weg und der langsame.
WP_Query nach Datum filtern mit date_query
Native Datumsabfragen sind leistungsfähig und effizient. Nutzen Sie sie statt manueller SQL-Filter.
$args = [
'date_query' => [
[
'after' => 'January 1st, 2025',
'before' => [
'year' => 2026,
'month' => 12,
'day' => 31,
],
'inclusive' => true,
],
],
];Typische WP_Query-Performance-Fehler
Zufällige Sortierung mit orderby rand
ORDER BY RAND() lässt MySQL jeder passenden Zeile einen Zufallswert zuweisen und die gesamte Menge sortieren, meist in einer temporären Tabelle, bevor das LIMIT greift. Der Core weigert sich außerdem, das zu cachen: Der Query-Cache aus WordPress 6.1 überspringt jede Anfrage, deren ORDER BY RAND( enthält.
Technische Lösung:
- IDs der letzten 50 Beiträge holen.
- In PHP 5 zufällige IDs auswählen.
- Eine zweite Abfrage mit
post__in => $random_idsausführen.
meta_query auf einen nicht indizierten meta_key
Das ist die häufigste Einzelursache für langsame Archivseiten in älteren WooCommerce-Installationen. Die schema.php des Core legt wp_postmeta mit zwei einspaltigen Indizes an, post_id und einem Präfix von meta_key, aber ohne zusammengesetzten Index. Der meta_key-Index hat zu geringe Kardinalität, um zu helfen, wenn WordPress nach meta_key = '_stock_status' AND meta_value = 'instock' filtert: MySQL muss trotzdem jede Zeile mit diesem Key scannen.
Aus der Praxis: ein polnischer WooCommerce-Shop mit 50.000 Produkten. Die Shop-Archivseite hing per pre_get_posts eine meta_query an, die nach _stock_status und einem eigenen Feld _brand filterte. Die P95-TTFB der Shopseite lag bei 4,1 Sekunden. SAVEQUERIES zeigte 80 % der Zeit in einem einzigen JOIN gegen wp_postmeta. Die Lösung:
ALTER TABLE wp_postmeta
ADD INDEX idx_meta_key_post_id (meta_key(32), post_id);Mit dem Index fiel dieselbe Abfrage auf 180 ms. Am PHP-Code änderte sich nichts. Für Werte, die in echten Bereichsvergleichen vorkommen ('compare' => '>', 'compare' => 'BETWEEN'), brauchen Sie zusätzlich einen Index auf meta_value, typischerweise einen Präfix-Index, weil die Spalte LONGTEXT ist.
Ist der Wertebereich fest (Größen, Farben, Marken-Slugs), verzichten Sie ganz auf meta_query und registrieren Sie eine eigene Taxonomie. wp_term_relationships hat einen Primärschlüssel auf (object_id, term_taxonomy_id) und einen separaten Index auf term_taxonomy_id. Derselbe Filter, der als meta_query 1,8 s brauchte, läuft als tax_query in 30-60 ms.
tax_query-Klauseln summieren sich bei großen Datenmengen
WP_Tax_Query löst Ihre Terms zuerst in term_taxonomy_id-Werte auf, danach fügt jede IN-Klausel einen LEFT JOIN gegen wp_term_relationships hinzu. AND-Klauseln werden zu einer korrelierten COUNT-Subquery, NOT IN zu einer Subquery, und nur EXISTS und NOT EXISTS greifen auf wp_term_taxonomy zu. Jede weitere Taxonomie in derselben Abfrage bringt einen weiteren Join oder eine weitere Subquery, die MySQL kombinieren muss. Facettenfilter-Seiten, die sechs Taxonomien zusammensetzen (Größe, Farbe, Marke, Saison, Material, Preisspanne), trifft das zuerst. Entweder denormalisieren Sie in eine einzige Facetten-Taxonomie, wechseln zu FacetWP oder SearchWP, die außerhalb der Tabellen indizieren, oder verlagern die Facettierung in eine Suchmaschine (Elasticsearch über ElasticPress oder Algolia für reine Lesekataloge).
paged versus page
Paginierung in Custom Loops scheitert oft an der falschen Query-Var. Das typische Symptom: Die Links zeigen auf /page/2/, aber die Seite liefert eine 404 oder wieder die ersten Beiträge. WordPress kennt die Seitenzahl Ihres eigenen WP_Query nicht. Sie müssen sie aus der passenden Query-Var lesen und als paged übergeben.
- Auf Blog-/Archiv-URLs ist die Variable
paged(/page/2/). - Auf statischen Seiten (Page-Template mit eigenem Loop) ist es oft
page.
$paged = max(
1,
(int) get_query_var( 'paged' ),
(int) get_query_var( 'page' )
);
$loop = new WP_Query(
[
'post_type' => 'post',
'posts_per_page' => 10,
'paged' => $paged,
]
);Übergeben Sie paged in den Args der sekundären Query. Der Hauptloop und Ihr Custom Loop teilen sich sonst die Seitenzahl nicht. Für Pretty-URLs prüfen Sie Rewrite-Regeln und dass paginate_links() / the_posts_pagination() die gleiche Basis-URL nutzen wie die Query.
Kombinieren Sie offset nicht mit paged. Das Argument offset überschreibt die Seitenberechnung, sodass jede Seite denselben Ausschnitt liefert. Brauchen Sie «einen Featured-Beitrag, dann eine paginierte Liste», nutzen Sie zwei Queries mit klarer Trennung oder berechnen Sie den Offset pro Seite selbst.
wp_reset_postdata nach sekundären Loops
$related->the_post() setzt das globale $post. Danach denken Template-Tags wie the_title(), get_permalink() und Conditional Helpers, sie seien noch im Related-Beitrag. Nach dem sekundären Loop:
wp_reset_postdata();In wp-includes/query.php ist der Unterschied zu wp_reset_query() zwei Zeilen lang. wp_reset_postdata() ruft $wp_query->reset_postdata() auf, das das globale $post und die übrigen Loop-Globals aus der Hauptabfrage wiederherstellt. wp_reset_query() setzt zuerst $wp_the_query zurück in $wp_query und ruft dann wp_reset_postdata() auf. wp_reset_query() brauchen Sie nur nach query_posts(), und dort besteht die Lösung darin, query_posts() zu entfernen.
Iterieren Sie mit foreach über ein Array aus get_posts(), kennen Template-Tags den Beitrag erst, wenn Sie setup_postdata( $post ) aufrufen. Danach setzen Sie genauso zurück:
global $post;
$items = get_posts( [ 'numberposts' => 3 ] );
foreach ( $items as $post ) {
setup_postdata( $post );
the_title( '<li>', '</li>' );
}
wp_reset_postdata();setup_postdata() weist das globale $post nicht selbst zu. Deshalb ist die Schleifenvariable hier das globale $post.
In Block-Themes und Hybrid-Setups (FSE + klassische Template Parts) ist der Bug besonders heimtückisch: der Related-Loop im Footer überschreibt den Post der Single-Seite, und der Author-Box oder Schema-Block liest plötzlich den falschen Titel.
Wann WP_Query eine Abfrage in IDs und Objekte aufteilt
Der Core holt nicht immer volle Zeilen in einem SELECT. Wurde das SQL in WP_Query::get_posts() nicht durch posts_*-Filter verändert und ist entweder ein externer Object Cache aktiv oder hat die Abfrage ein LIMIT mit posts_per_page unter 500, selektiert WordPress zuerst nur die IDs und befüllt danach die Post-Objekte, aus dem Object Cache, wo sie bereits liegen. Der Filter split_the_query kann das ein- oder ausschalten.
Zwei Folgen fürs Debugging:
- Das SQL in Query Monitor kann
SELECT wp_posts.ID ...sein, gefolgt von einem separaten Abruf der fehlenden Beiträge. Das ist Core-Verhalten, kein Plugin-Bug. - Jedes Plugin, das das SQL über die
posts_*-Klauselfilter ändert, schaltet die Aufteilung ab. Ein Plugin, das die selektierten Felder ändert (posts_fields,posts_fields_request,posts_clauses_request), schaltet für diesen Request außerdem den Query-Cache aus 6.1 ab.
Es gibt auch einen vollständigen Kurzschluss: Der Filter posts_pre_query läuft vor dem Datenbankaufruf, und liefert er ein Array zurück, überspringt WordPress das SQL komplett. Such-Plugins beantworten WP_Query so aus einem externen Index. Zeigt Query Monitor eine WP_Query mit Ergebnissen, aber ohne passendes SQL, suchen Sie nach einem posts_pre_query-Callback.
Wie Query Monitor langsame WP_Query findet
Raten Sie nicht. Messen Sie. Query Monitor von John Blackbourn gehört auf Staging, nie auf Produktion. Es zeigt pro Request:
- Anzahl der Datenbankqueries
- langsame Queries mit Caller (Theme, Plugin,
pre_get_posts); das Queries-Panel gruppiert nach Komponente, sodass Sie sofort sehen, ob 2 Sekunden TTFB aus Ihrem Theme, aus dem WooCommerce-Core oder aus einem Drittanbieter-Plugin ampre_get_posts-Hook kommen - doppelte Queries (gleicher SQL-String zweimal); der Tab für Duplikate fängt N+1-Loops, in denen ein Template-Tag innerhalb von
the_post()die Datenbank pro Zeile erneut abfragt - HTTP-API-Calls und Capability-Checks als Nebenkosten
Drei weitere Werkzeuge liefern die Antwort, wenn Query Monitor allein nicht reicht:
SAVEQUERIES. Setzen Siedefine( 'SAVEQUERIES', true );in derwp-config.php, nur auf Staging, weil jede Query des Requests im Speicher gehalten wird. WordPress befüllt dann$wpdb->queries; jeder Eintrag enthält das SQL, seine Dauer, den Caller-Stack, die Startzeit und optionale eigene Daten (wpdb::log_query()im Core).error_log( print_r( $wpdb->queries, true ) )beishutdownliefert den kanonischen Trace «was lief und wie lange».$query->requestplusEXPLAIN. Nachnew WP_Query()gibtecho $query->request;das SQL aus, das WordPress tatsächlich erzeugt hat. Führen Sie diesen String mitEXPLAINin TablePlus oder permysql -eaus. Achten Sie auftype: ALL(Full Table Scan),Using filesortoderUsing temporary: Das sind die Zeilen, die einen Index brauchen.- WP-CLI.
wp db query --skip-column-names "EXPLAIN $SQL"führtEXPLAINgegen die Live-Datenbank aus, ohne Browser-Overhead.wp post list --post_type=product --fields=ID --posts_per_page=10reproduziert die Abfragelogik in einer deterministischen Umgebung, nützlich, wenn die langsame Seite hinter einer Paywall oder einem Login liegt.
Eine Faustregel zum Priorisieren: Jede Query über 50 ms ist bei warmem Cache verdächtig, alles über 200 ms ist kaputt. web.dev setzt eine gute TTFB bei 0,8 Sekunden oder weniger an; frisst eine einzige WP_Query davon 250 ms, ist ein großer Teil des Budgets weg, bevor das Template zu rendern beginnt. TTFB ist selbst kein Core Web Vital, aber der erste Teil der LCP-Zeit, also taucht jede Millisekunde davon im LCP auf.
Workflow, der in DE-Agentur-Reviews funktioniert:
- Staging mit Query Monitor, eingeloggt als Admin und einmal als ausgeloggter Gast (Cache-Unterschiede).
- Archiv, Single und eine Builder-lastige Landing öffnen.
- Alles über ~50 Queries oder doppelte
WP_Querymit gleichem Args-Fingerprint markieren. - Zuerst Hauptabfrage via
pre_get_postskorrigieren, dann sekundäre Loops mit Flags abspecken.
Ohne persistenten Object Cache läuft derselbe Meta-Prime bei jedem Request erneut; innerhalb eines Requests dedupliziert der nicht persistente Cache ihn. Mit Redis/Memcached (häufig auf Raidboxes und eigenen VPS) sinkt die Query-Zahl, aber falsch gebaute Loops bleiben sichtbar als CPU-Zeit in PHP, nicht nur als SQL.
Achten Sie auf den Caller-Stack: kommt die Query aus dem Child Theme, aus einem Must-Use-Plugin oder aus einem Page-Builder-Widget? Builder-Widgets feuern oft erst beim Render und umgehen damit Ihre Annahmen über «eine Query pro Template». Markieren Sie den Stack in Query Monitor, bevor Sie Args «optimieren», die eigentlich doppelt registriert sind.
WP_Query mit Object Cache und Transients cachen
Seit WordPress 6.1 cacht der Core die ID-Liste wiederholter, identischer Abfragen bereits im Object Cache (cache_results); Details stehen im oben verlinkten Leitfaden zu fortgeschrittenem WP_Query. Die Muster unten decken ab, was dieser Cache nicht abdeckt.
Wiederholte «Top 5»- oder Mega-Menü-Queries gehören hinter einen Transient oder einen Object-Cache-Key:
$cache_key = 'home_featured_ids_v1';
$ids = wp_cache_get( $cache_key, 'wppoland_queries' );
if ( false === $ids ) {
$q = new WP_Query(
[
'post_type' => 'post',
'posts_per_page' => 5,
'fields' => 'ids',
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
]
);
$ids = $q->posts;
wp_cache_set( $cache_key, $ids, 'wppoland_queries', HOUR_IN_SECONDS );
}Invalidieren Sie bei save_post / deleted_post für denselben Post Type. Transients ohne persistenten Object Cache landen in der Options-Tabelle - auf stark befahrenen Sites lieber echten Object Cache nutzen und Keys bewusst versionieren (_v1, _v2).
Page-Cache (nginx FastCGI Cache, Cloudflare, Plugin-Full-Page-Cache) ersetzt Object Cache nicht. Der erste uncached Hit baut weiterhin alle Loops. Object Cache macht genau diesen Hit billiger.
WP_Query-Ergebnisse in der REST API anpassen
Bauen Sie ein React-Frontend mit Headless WordPress, nutzen Sie WP_Query nicht direkt in JavaScript, aber die REST API verwendet es im Backend.
REST-API-Abfrageargumente filtern
Mit dem Filter rest_{post_type}_query ändern Sie, wie die API die Datenbank abhängig von URL-Parametern abfragt. Prüfen Sie zuerst den Endpoint: /wp/v2/posts akzeptiert bereits einen nativen Parameter exclude, ein eigener Parameter lohnt sich also nur für Logik, die der Core nicht abdeckt.
add_filter( 'rest_post_query', function( $args, $request ) {
$exclude = $request->get_param( 'exclude_ids' );
if ( ! empty( $exclude ) ) {
$args['post__not_in'] = wp_parse_id_list( $exclude );
}
return $args;
}, 10, 2 );wp_parse_id_list() macht aus einem kommagetrennten String ein Array positiver Ganzzahlen, sodass ein manipulierter Parameter nichts anderes in die Abfrage einschleusen kann.
Wann get_posts() statt WP_Query reicht
get_posts() ist ein schlanker Wrapper um WP_Query, der ein Array von WP_Post-Objekten statt der Loop-API zurückgibt. Laut post.php im Core setzt er immer 'no_found_rows' => true und 'ignore_sticky_posts' => true und hat 'suppress_filters' => true als Default.
$posts = get_posts(
[
'post_type' => 'post',
'posts_per_page' => 5,
'fields' => 'ids',
'suppress_filters' => false, // Filter aktiv lassen, wenn Plugins greifen sollen.
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
]
);Nutzen Sie get_posts(), wenn Sie nur eine Liste brauchen und keinen Loop mit Template-Tags. Bleiben Sie bei WP_Query, wenn Sie have_posts(), Paginierung (max_num_pages) oder explizite Kontrolle über Found-Rows brauchen.
Achtung bei suppress_filters => true: pre_get_posts feuert trotzdem immer, weil WP_Query::get_posts() den Hook per do_action_ref_array() unabhängig von diesem Argument auslöst. suppress_filters schaltet nur die posts_*-SQL-Filter ab, etwa posts_where, posts_join, posts_clauses und the_posts. Ein Plugin, das seine Logik in diese Filter hängt, greift bei get_posts() also standardmäßig nicht. Brauchen Sie diese Filter, setzen Sie suppress_filters => false.
Für Admin-AJAX- oder REST-Callbacks, die nur IDs für ein Select2-Feld brauchen, ist get_posts() mit fields => 'ids' meist klarer als ein voller Loop. Für Template-Rendering mit the_title() und Thumbnail-Tags bleiben Sie bei WP_Query und dem Reset-Pfad.
Checkliste für WP_Query vor dem Merge
- Ist die Änderung an der Ergebnismenge eine Hauptabfrage? Dann
pre_get_posts, nicht zweite Query. Vorhandenesquery_posts()ersetzen Sie durchpre_get_posts(Hauptabfrage) odernew WP_Query(sekundäre Loops). - Hat jeder
pre_get_posts-Callback einen Guard mitis_admin()und$query->is_main_query()? - Braucht der sekundäre Loop Seitenzahlen? Sonst
no_found_rows => true. - Reichen IDs? Dann
fields => 'ids'und Meta/Term-Cache aus. - Ist
paged/pagekorrekt gesetzt und getestet auf/page/2/? - Folgt auf jeden
the_post()eines Custom Loops einwp_reset_postdata()? - Query Monitor auf der langsamen URL, nach Zeit sortiert: Die längste Query ist fast immer die, die Sie beheben müssen. Keine Doppel-Queries, kein
-1, kein unnötigermeta_query. - Filtert eine
meta_querynach einem nicht indiziertenmeta_key? DannINDEX (meta_key(32), post_id)aufwp_postmetaanlegen. - Wiederholte teure Listen: Object Cache oder Transient mit Invalidierung.
Saubere Queries sind kein «Nice-to-have» für PHP 8.x-Deployments. Sie sind der Unterschied zwischen einem Shop, der Black-Friday-Traffic hält, und einem, der bei 40 parallelen PHP-Workern in die Knie geht.
Mehr Kontext zur Mess- und Caching-Seite: WordPress-Geschwindigkeitsoptimierung.







