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 merkst du 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.
Offizielle Referenzen: WP_Query, pre_get_posts und Performance-Optimierung.
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();
}Nutze niemals query_posts(). Die Funktion überschreibt die Hauptabfrage mitten im Request, zerstört Paginierung und Conditional Tags und erzwingt einen Extra-Datenbankhit. Wenn du ändern willst, was ein Archiv zeigt, nutze pre_get_posts. Wenn du zusätzlichen Content neben der Hauptliste brauchst, nutze new WP_Query().
In Agenturprojekten mit Page Buildern (Elementor, Breakdance) siehst du 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ürst du 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. Du bekommst 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' );
}
}
);Drei Regeln, die Debugging-Stunden sparen:
- Guard mit
is_admin()und$query->is_main_query(). Ohne sie triffst du REST, Feeds, Admin-Listen und jeden sekundären Loop. - Setze Argumente mit
$query->set(), ersetze nicht das ganze Objekt. - Rufe in der Callback-Funktion keine
get_posts()odernew WP_Query()ohne harte Begründung auf. Das erzeugt neue Queries während der Query, die du gerade justierst.
Auf WooCommerce-Archiven ist die Hauptabfrage die Produktliste. Filtere 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 du post_type auf der Startseite einschränkst, prüfe, dass du RSS und Suchergebnisse nicht leer räumst.
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.
Performance-Flags: no_found_rows und fields => ids
Standard-WP_Query berechnet auch die Gesamtzahl der Treffer für Paginierung (FOUND_ROWS() / historisch SQL_CALC_FOUND_ROWS). Brauchst du keine Seitenzahlen, schalte 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 du danach gezielt get_post() / Meta nur für sichtbare Karten lädst. update_post_meta_cache und update_post_term_cache auf false verhindern Batch-Primes, die du nicht brauchst.
posts_per_page => -1 bleibt verboten. Auch «nur 200 Case Studies» ohne Limit werden irgendwann 2 000. Setze immer eine Obergrenze und paginiere oder cache.
Wenn du Paginierung brauchst, lass no_found_rows weg (oder setze 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:
- Preferiere Taxonomien gegenüber
meta_queryfür filterbare Attribute (Stadt, Branche, Produktlinie). Meta-Joins skalieren schlechter. - Nutze
field => 'term_id', wenn du IDs hast. Slugs erzeugen Extra-Lookups. include_children => falseauf hierarchischen Taxonomien, wenn du keine Kind-Terms willst - sonst holt WordPress die ganze Unterbaum-Logik mit.- Kombiniere
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.
paged versus page
Paginierung in Custom Loops scheitert oft an der falschen Query-Var.
- 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,
]
);Übergib paged in den Args der sekundären Query. Der Hauptloop und dein Custom Loop teilen sich sonst die Seitenzahl nicht. Für Pretty-URLs prüfe Rewrite-Regeln und dass paginate_links() / the_posts_pagination() die gleiche Basis-URL nutzen wie die Query.
offset und paged gleichzeitig zu setzen ist eine bekannte Falle: Offset verschiebt die Ergebnismenge und macht klassische Seitenzahlen inkonsistent. Wenn du Offset brauchst (z. B. Featured oben, Rest darunter), berechne die Seite manuell oder nutze zwei Queries mit klarer Trennung.
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();Das stellt den globalen Post der Hauptabfrage wieder her. wp_reset_query() brauchst du nur, wenn du query_posts() missbraucht hast - und den Missbrauch solltest du entfernen statt zu resetten.
Wenn du über ein Array von WP_Post-Objekten iterierst ohne WP_Query::the_post(), nutze setup_postdata( $post ) und danach ebenfalls wp_reset_postdata().
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.
Query Monitor als Messwerkzeug
Rate nicht. Miss. Query Monitor zeigt pro Request:
- Anzahl der Datenbankqueries
- langsame Queries mit Caller (Theme, Plugin,
pre_get_posts) - doppelte Queries (gleicher SQL-String zweimal)
- HTTP-API-Calls und Capability-Checks als Nebenkosten
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 Object Cache siehst du denselben Meta-Prime mehrfach. 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.
Achte 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 deine Annahmen über «eine Query pro Template». Markiere den Stack in Query Monitor, bevor du Args «optimierst», die eigentlich doppelt registriert sind.
Object Cache und Transients um teure Loops
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 );
}Invalidiere 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.
Wann get_posts() reicht
get_posts() ist ein Wrapper um WP_Query mit Defaults, die oft schon performanter sind: no_found_rows effektiv true-artig für typische Nutzung, und Rückgabe eines Arrays von Posts statt Loop-API.
$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,
]
);Nutze get_posts(), wenn du nur eine Liste brauchst und keinen Loop mit Template-Tags. Bleib bei WP_Query, wenn du have_posts(), Paginierung (max_num_pages) oder explizite Kontrolle über Found-Rows brauchst.
Achtung: suppress_filters => true (historischer Default in manchen Contexten) umgeht pre_get_posts und andere Filter. Für konsistentes Verhalten mit dem Rest der Site setze suppress_filters => false, sofern du die Filter wirklich willst.
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 bleib bei WP_Query und dem Reset-Pfad.
Checkliste vor dem Merge
- Ist die Änderung an der Ergebnismenge eine Hauptabfrage? Dann
pre_get_posts, nicht zweite 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: keine Doppel-Queries, kein
-1, kein unnötigermeta_query. - 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.







