Der umfassende Leitfaden zu WP_Query & The Loop (2026 Edition)

Der umfassende Leitfaden zu WP_Query & The Loop (2026 Edition)

Zuletzt überprüft: 13. Oktober 2026
18 Min. Lesezeit
Leitfaden
Full-Stack-Entwickler
Core Web Vitals

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:

  1. Argumente parsen: Ihr Argument-Array wird in ein standardisiertes Format überführt.
  2. SQL erzeugen: Daraus entsteht ein komplexes SQL-SELECT.
  3. Abfrage ausführen: Die Anfrage geht an die Datenbank.
  4. 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 von WP_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:

  1. Rufen Sie Conditionals als Methoden auf dem übergebenen Objekt auf ($query->is_home()), nicht als globale Funktionen. Das globale is_main_query() liest $wp_query, nicht die gerade gefilterte Abfrage. Im Core ist WP_Query::is_main_query() ein einziger Vergleich: $wp_the_query === $this.
  2. Guard mit is_admin() und $query->is_main_query(). Ohne beide treffen Sie REST, Feeds, Admin-Listen und jeden sekundären Loop.
  3. 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.
  4. Setzen Sie Argumente mit $query->set(), ersetzen Sie nicht das ganze Objekt.
  5. Rufen Sie in der Callback-Funktion keine get_posts() oder new 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 Action loop_start. Hat die Abfrage keine vollständigen Post-Objekte geliefert, primt er an dieser Stelle auch Post-, Meta- und Term-Caches (außer update_post_meta_cache oder update_post_term_cache steht auf false).
  • Mit 'fields' => 'ids' macht der aktuelle Core aus jeder ID in the_post() per get_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, $more und $numpages, danach feuert es die Action the_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 es loop_end und spult die Abfrage zurück. Hat die Abfrage nichts gefunden, feuert es stattdessen loop_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_query fü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 => false auf hierarchischen Taxonomien, wenn Sie keine Kind-Terms wollen - sonst holt WordPress die ganze Unterbaum-Logik mit.
  • Kombinieren Sie tax_query nicht mit unnötigem meta_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:

  1. IDs der letzten 50 Beiträge holen.
  2. In PHP 5 zufällige IDs auswählen.
  3. Eine zweite Abfrage mit post__in => $random_ids ausfü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 am pre_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:

  1. SAVEQUERIES. Setzen Sie define( 'SAVEQUERIES', true ); in der wp-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 ) ) bei shutdown liefert den kanonischen Trace «was lief und wie lange».
  2. $query->request plus EXPLAIN. Nach new WP_Query() gibt echo $query->request; das SQL aus, das WordPress tatsächlich erzeugt hat. Führen Sie diesen String mit EXPLAIN in TablePlus oder per mysql -e aus. Achten Sie auf type: ALL (Full Table Scan), Using filesort oder Using temporary: Das sind die Zeilen, die einen Index brauchen.
  3. WP-CLI. wp db query --skip-column-names "EXPLAIN $SQL" führt EXPLAIN gegen die Live-Datenbank aus, ohne Browser-Overhead. wp post list --post_type=product --fields=ID --posts_per_page=10 reproduziert 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:

  1. Staging mit Query Monitor, eingeloggt als Admin und einmal als ausgeloggter Gast (Cache-Unterschiede).
  2. Archiv, Single und eine Builder-lastige Landing öffnen.
  3. Alles über ~50 Queries oder doppelte WP_Query mit gleichem Args-Fingerprint markieren.
  4. Zuerst Hauptabfrage via pre_get_posts korrigieren, 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

  1. Ist die Änderung an der Ergebnismenge eine Hauptabfrage? Dann pre_get_posts, nicht zweite Query. Vorhandenes query_posts() ersetzen Sie durch pre_get_posts (Hauptabfrage) oder new WP_Query (sekundäre Loops).
  2. Hat jeder pre_get_posts-Callback einen Guard mit is_admin() und $query->is_main_query()?
  3. Braucht der sekundäre Loop Seitenzahlen? Sonst no_found_rows => true.
  4. Reichen IDs? Dann fields => 'ids' und Meta/Term-Cache aus.
  5. Ist paged / page korrekt gesetzt und getestet auf /page/2/?
  6. Folgt auf jeden the_post() eines Custom Loops ein wp_reset_postdata()?
  7. 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ötiger meta_query.
  8. Filtert eine meta_query nach einem nicht indizierten meta_key? Dann INDEX (meta_key(32), post_id) auf wp_postmeta anlegen.
  9. 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.

Nächster Schritt

Machen Sie aus dem Artikel eine echte Umsetzung

Dieser Block stärkt die interne Verlinkung und führt Nutzer gezielt zum nächsten sinnvollen Schritt im Service- und Content-System.

Soll das Thema auf Ihrer Website umgesetzt werden?

Wenn Core Web Vitals, Rendering oder WordPress-Overhead das Problem sind, setze ich einen klaren Optimierungsplan auf und implementiere ihn.

Artikel-FAQ

Häufig gestellte Fragen

Praktische Antworten zur Umsetzung des Themas.

SEO-readyGEO-readyAEO-ready3 Q&A
Warum ist mein WP_Query langsam, obwohl es nur zehn Beiträge liefert?#
Ohne 'no_found_rows' => true schickt WP_Query ein zusätzliches SELECT FOUND_ROWS(), das die gesamte Treffermenge hinter dem LIMIT durchläuft. Auf einer wp_posts-Tabelle mit 200.000 Zeilen und einem JOIN aus tax_query kostet dieser zweite Schritt 80 bis 250 ms, auch wenn nur zehn Zeilen gerendert werden. Setzen Sie 'no_found_rows' => true, außer die Seite zeigt tatsächlich eine Gesamtzahl an.
Wann sollte ich get_posts statt new WP_Query verwenden?#
get_posts ist ein schlanker Wrapper, der standardmäßig suppress_filters => true und no_found_rows => true setzt. Nutzen Sie ihn für kurze Sidebar-Listen mit fester Länge, wenn Sie weder eine Iteration mit the_post() noch Paginierung brauchen. Greifen Sie zu new WP_Query, wenn Sie vollen Zugriff auf Template-Tags, max_num_pages oder bewusst aktive posts_*-Filter brauchen.
Wie repariere ich eine meta_query, die bei einem großen Katalog Sekunden dauert?#
wp_postmeta bringt getrennte einspaltige Indizes auf post_id und meta_key mit, aber keinen zusammengesetzten Index, und der meta_key-Index allein ist für Suchen nach Werten zu wenig selektiv. Legen Sie einen zusammengesetzten Index an: ALTER TABLE wp_postmeta ADD INDEX idx_meta_key_post_id (meta_key(32), post_id). Für Bereichsvergleiche auf meta_value ergänzen Sie dort einen Präfixindex. Ist der Wertebereich fest (Größen, Marken), migrieren Sie auf eine eigene Taxonomie, denn wp_term_relationships ist für die JOINs, die WordPress erzeugt, bereits indiziert.

Sie brauchen ein FAQ für Branche und Zielmarkt? Wir erstellen eine Version passend zu Ihren Business-Zielen.

Kontakt aufnehmen

Ähnliche Artikel