WP_Query e The Loop: performance, taxonomias e templates

WP_Query e The Loop: performance, taxonomias e templates

Última verificação: 13 de outubro de 2026
23 min de leitura
Guia
Desenvolvedor full-stack
Core Web Vitals

WP_Query é a classe que o WordPress usa para ir buscar conteúdos à base de dados: arquivos, posts singulares, endpoints REST, loops da loja WooCommerce. Cada arquivo, resultado de pesquisa e página singular começa com uma consulta principal. Loops secundários são instâncias extra de WP_Query que coloca em templates, widgets ou shortcodes. Quando uma página está lenta, a causa costuma ser uma de três: um meta_query contra um meta_key sem índice, um posts_per_page sem limite, ou SQL_CALC_FOUND_ROWS a correr em cada carregamento quando nada na página mostra um total.

Em alojamento partilhado ou numa VPS com nginx e php-fpm isto aparece primeiro como workers PHP esgotados e TTFB alto, não como um aviso bonito no painel. Este guia cobre consulta principal versus WP_Query secundária, pre_get_posts, flags de desempenho, armadilhas de tax_query e meta_query, paginação, wp_reset_postdata(), Query Monitor, object cache e quando get_posts() chega.

Referência oficial: WP_Query, pre_get_posts, o código de class-wp-query.php e otimização de performance.

#Como o WP_Query constrói e executa um pedido

Quando instancia WP_Query, o WordPress faz quatro passos:

  1. Analisa os argumentos: converte o seu array de argumentos num formato normalizado.
  2. Gera o SQL: monta uma instrução SQL SELECT.
  3. Executa a consulta: envia o pedido à base de dados.
  4. Preenche o objeto: carrega o objeto WP_Query com objetos de post e metadados.

#Propriedades do objeto WP_Query

$query = new WP_Query( $args ) não devolve só um array de posts. Devolve um objeto com propriedades que vai usar constantemente:

  • $query->posts: array de objetos WP_Post.
  • $query->post_count: número de posts mostrados na página atual.
  • $query->found_posts: total de posts que cumprem os critérios, independentemente da paginação.
  • $query->max_num_pages: número total de páginas de resultados.
  • $query->query_vars: os argumentos que o WordPress usou de facto para correr a consulta.

#WP_Query, get_posts e query_posts não são intercambiáveis

Há três construtores em uso no terreno:

  • new WP_Query( $args ): objeto completo, expõe found_posts e max_num_pages e suporta iteração com the_post(). Use-o quando precisa de paginação ou de acesso completo às template tags.
  • get_posts( $args ): wrapper fino que devolve um array de objetos WP_Post. Por omissão usa 'suppress_filters' => true e define sempre 'no_found_rows' => true e 'ignore_sticky_posts' => true. É mais rápido, mas salta os filtros posts_*. Use-o para listas curtas e de tamanho fixo na sidebar.
  • query_posts(): destrutivo. Substitui a global $wp_query por uma nova consulta, parte as condicionais is_*() mais abaixo na página e corre uma segunda consulta principal por cima da que o WordPress já correu. A referência da função diz que não se destina a plugins nem a temas. Não o use. Se o encontrar num tema herdado, substitua-o por pre_get_posts para a consulta principal e por new WP_Query para tudo o resto.

#Consulta principal versus WP_Query secundária

A consulta principal ($wp_the_query / a global $wp_query) é construída antes do template carregar. Define que template o WordPress escolhe (is_home(), is_archive(), is_singular(), etc.) e que posts have_posts() / the_post() mostram no loop padrão.

if ( have_posts() ) :
	while ( have_posts() ) :
		the_post();
		get_template_part( 'template-parts/content', get_post_type() );
	endwhile;
endif;

Um loop secundário é uma nova instância. Não altera a consulta principal e não deve servir para «corrigir» filtros de arquivo.

$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();
}

Se precisa de mudar o que o arquivo mostra, use pre_get_posts. Se precisa de conteúdo extra ao lado da lista principal, use new WP_Query(). Um segundo WP_Query num template de arquivo para filtrar a mesma lista dá duas listagens, paginação a apontar para o sítio errado e o dobro do SQL em cada pedido.

#Alterar a consulta principal com pre_get_posts

A referência de pre_get_posts indica que o hook dispara depois de criado o objeto de variáveis da consulta e antes de a consulta ser executada. É esse o ponto: altera os argumentos uma vez e o WordPress continua a correr uma só consulta, em vez de correr a consulta principal e depois outro WP_Query por cima.

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 ] ); // ex.: «Newsletter».
		}

		if ( $query->is_post_type_archive( 'caso' ) ) {
			$query->set( 'orderby', 'menu_order title' );
			$query->set( 'order', 'ASC' );
		}
	}
);

Regras que vêm da página de referência e do core:

  1. Chame as condicionais como métodos do objeto recebido ($query->is_home()), não como funções globais. A função global is_main_query() lê $wp_query, não a consulta que está a ser filtrada. No core, WP_Query::is_main_query() é uma única comparação: $wp_the_query === $this.
  2. Proteja com is_admin() e $query->is_main_query(). Sem os dois, o callback também altera as listagens do admin, feeds, pedidos REST e cada loop secundário da página.
  3. is_front_page() não funciona dentro do hook, porque a consulta ainda não está preparada; is_home() funciona. Para uma página inicial estática, inspecione as query vars.
  4. Defina argumentos com $query->set(), sem substituir o objeto inteiro. Nunca chame query_posts() nem um segundo new WP_Query() dentro do callback: cria consultas novas durante a construção da que está a ajustar.

Em arquivos WooCommerce a consulta principal é a lista de produtos. Filtre aí, não com um loop extra em archive-product.php, se o objetivo é o mesmo URL e a mesma paginação.

Feeds (is_feed()) e pesquisa (is_search()) também são consultas principais. Se limitar post_type na home, confirme que não esvazia o RSS nem os resultados de pesquisa. Use condições explícitas por contexto.

#Acelerar WP_Query com no_found_rows e fields ids

Sem no_found_rows, e sempre que a consulta tem LIMIT, o WP_Query acrescenta SQL_CALC_FOUND_ROWS ao SELECT e depois corre SELECT FOUND_ROWS() (através do filtro found_posts_query). O MySQL tem de continuar a contar depois de atingir o LIMIT, o que numa tabela wp_posts com 200.000 linhas e um JOIN de tax_query acrescenta tipicamente 80-250 ms por pedido, conforme o estado da cache. Se a página não mostra “Página 3 de 47” nem um total, desligue:

$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,
	]
);

As ligações “seguinte” e “anterior” funcionam sem found_posts: peça posts_per_page + 1 posts, mostre posts_per_page, e a linha a mais indica que existe página seguinte. Em arquivos que mostram mesmo um total (“A mostrar 1-10 de 4.200 artigos”), mantenha a contagem, mas guarde-a num transient de 5 minutos com a chave baseada na assinatura dos filtros. A contagem raramente muda em cinco minutos; o scan do JOIN não precisa de se repetir em cada carregamento.

Quando só precisa de IDs (relacionados, listas de exclusão, chaves de transient), fields => 'ids' é a opção mais barata: menos dados, sem hidratar WP_Post, e pode reutilizar os IDs em post__in.

update_post_meta_cache e update_post_term_cache ajudam quando o loop nunca chama get_post_meta() nem tags de taxonomia. Se o template vai buscar imagem em destaque, categorias ou campos ACF, deixe-os em true (omissão). Desligar e depois ir buscar meta por post gera N+1 pior do que um único warm de cache.

#Porque é que posts_per_page => -1 esgota a memória

-1 diz ao WP_Query para carregar todas as linhas para memória como objetos WP_Post hidratados e depois preparar as caches de meta e de termos para todas elas (update_postmeta_cache() e update_object_term_cache() no core). Num CPT que cresceu de 50 para 5.000 entradas em dois anos, o mesmo código que corria em 80 ms passa a alocar mais de 200 MB de memória PHP e rebenta o teto de memory_limit em alojamento partilhado.

A correção é um limite fixo que corresponda ao que é renderizado:

  • Um widget de sidebar que mostra 5 itens recebe 'posts_per_page' => 5.
  • Um gerador de sitemap que precisa mesmo de todas as linhas deve trabalhar em lotes com paged num ciclo, não com -1. 200 IDs por lote é um teto seguro nos alojamentos típicos.
  • Ferramentas internas de exportação, onde a pressão de memória é aceitável, devem mesmo assim usar 'fields' => 'ids' para não hidratar objetos de post completos.

Se precisa de todos os IDs (migração, CLI), também pode fazê-lo em WP-CLI ou num job em background, não num pedido HTTP atrás de php-fpm.

#Erros de desempenho no WP_Query que custam segundos

#Ordenar ao acaso com orderby rand

ORDER BY RAND() obriga o MySQL a atribuir um valor aleatório a cada linha encontrada e a ordenar o conjunto inteiro, normalmente numa tabela temporária, antes de aplicar o LIMIT. O core também se recusa a fazer cache disto: a query cache do WordPress 6.1 ignora qualquer pedido cujo ORDER BY contenha RAND(.

Correção:

  1. Vá buscar os IDs dos últimos 50 posts.
  2. Escolha 5 IDs ao acaso em PHP.
  3. Corra uma segunda consulta com post__in => $random_ids.

#meta_query contra um meta_key sem índice

É a causa mais comum de páginas de arquivo lentas em instalações WooCommerce antigas. O schema.php do core cria wp_postmeta com dois índices de coluna única, post_id e um prefixo de meta_key, e sem índice composto. O índice de meta_key tem cardinalidade baixa demais para ajudar quando o WordPress filtra por meta_key = '_stock_status' AND meta_value = 'instock': o MySQL continua a ter de percorrer todas as linhas com essa chave.

Caso real: uma loja WooCommerce polaca com 50.000 produtos. O arquivo da loja usava pre_get_posts para acrescentar um meta_query que filtrava por _stock_status e por um campo próprio _brand. O TTFB P95 da página da loja era de 4,1 segundos. O SAVEQUERIES mostrou 80% do tempo dentro de um único JOIN contra wp_postmeta. A correção:

ALTER TABLE wp_postmeta
  ADD INDEX idx_meta_key_post_id (meta_key(32), post_id);

Depois do índice, a mesma consulta desceu para 180 ms. Não mudou uma linha de PHP. A consulta é idêntica; só mudou o índice. Para valores que entram em comparações de intervalo reais ('compare' => '>', 'compare' => 'BETWEEN'), precisa também de um índice em meta_value, normalmente um índice de prefixo porque a coluna é LONGTEXT.

Quando o conjunto de valores é fixo (tamanhos, cores, slugs de marca), não use meta_query. Registe uma taxonomia personalizada. wp_term_relationships tem chave primária em (object_id, term_taxonomy_id) e um índice separado em term_taxonomy_id, por isso o mesmo filtro que demorava 1,8 s como meta_query corre em 30-60 ms como tax_query.

#Cláusulas de tax_query somam-se em escala

WP_Tax_Query começa por resolver os termos em valores term_taxonomy_id; depois cada cláusula IN acrescenta um LEFT JOIN contra wp_term_relationships. Cláusulas AND tornam-se uma subconsulta COUNT correlacionada, NOT IN uma subconsulta, e só EXISTS e NOT EXISTS tocam em wp_term_taxonomy. Cada taxonomia extra na mesma consulta acrescenta outro join ou subconsulta que o MySQL tem de combinar, por isso as páginas de filtros facetados que juntam seis taxonomias (tamanho, cor, marca, estação, material, faixa de preço) são as primeiras a sofrer. Ou desnormaliza tudo numa única taxonomia de facetas, ou passa para FacetWP / SearchWP, que indexam fora das tabelas, ou envia as facetas para um motor de pesquisa (Elasticsearch via ElasticPress, ou Algolia para catálogos só de leitura).

#Erros comuns com tax_query no WP_Query

tax_query é poderoso e fácil de tornar caro. Erros comuns:

OR entre taxonomias sem perceber o custo do join. Várias cláusulas com relation => 'OR' em taxonomias grandes geram joins largos. Prefira uma taxonomia com vários termos (operator => 'IN') quando fizer sentido.

Slug versus term_id. field => 'slug' lê-se bem, mas term_id mantém-se estável se o slug mudar. Em código que vive anos, guarde o ID do termo em config ou options.

Arrays vazios. Uma lista terms vazia pode dar zero resultados ou SQL inesperado, conforme o operador. Valide o input antes de montar a consulta.

meta_query para filtros que deveriam ser taxonomia. Meta é EAV (wp_postmeta). Os índices são mais fracos do que as relações de termos. Se o campo é um filtro (setor, cidade, linha de produto), registe uma taxonomia personalizada em vez de pesquisar por meta_key.

$args = [
	'post_type'      => 'post',
	'posts_per_page' => 10,
	'no_found_rows'  => true,
	'tax_query'      => [
		'relation' => 'AND',
		[
			'taxonomy' => 'category',
			'field'    => 'term_id',
			'terms'    => [ 4, 9 ],
			'operator' => 'IN',
		],
		[
			'taxonomy' => 'post_tag',
			'field'    => 'slug',
			'terms'    => [ 'wordpress', 'performance' ],
			'operator' => 'AND',
		],
	],
];

include_children é true por omissão em taxonomias hierárquicas. Em listas planas onde quer exatamente um termo, defina include_children => false de forma explícita.

#Filtrar por intervalo de datas com date_query

As consultas de data nativas são eficientes. Use-as em vez de filtrar com SQL manual.

$args = [
    'date_query' => [
        [
            'after'     => 'January 1st, 2025',
            'before'    => [
                'year'  => 2026,
                'month' => 12,
                'day'   => 31,
            ],
            'inclusive' => true,
        ],
    ],
];

#Paginação: paged versus page

O WordPress usa duas query vars:

  • paged para arquivos, índice do blog e a maioria das listas personalizadas.
  • page para paginação dentro de um conteúdo de página estática / front page (<!--nextpage-->).

Num loop secundário num arquivo:

$paged = max( 1, (int) get_query_var( 'paged' ) );

$list = new WP_Query(
	[
		'post_type'      => 'post',
		'posts_per_page' => 10,
		'paged'          => $paged,
	]
);

Numa página definida como front page estática (is_front_page() && is_page()) a consulta principal lê muitas vezes page, não paged. Muitos temas falham aqui: as ligações parecem corretas, mas o segundo loop começa sempre na página 1, ou a página 2 devolve 404. Leia ambas quando tiver dúvidas:

$paged = (int) get_query_var( 'paged' );
if ( $paged < 1 ) {
	$paged = (int) get_query_var( 'page' );
}
$paged = max( 1, $paged );

Para ligações de paginação em loops secundários: passe total e current a paginate_links() com base em $list->max_num_pages e $paged, não na global $wp_query. Caso contrário “página seguinte” aponta para o número de página do arquivo principal.

Não combine offset com paged. O argumento offset sobrepõe-se ao cálculo da página, por isso todas as páginas devolvem a mesma fatia. Se precisa de “um post em destaque e depois uma lista paginada”, corra duas consultas ou calcule o offset por página.

Quando o loop não deve paginar, defina no_found_rows => true e salte paginate_links(). Poupa a consulta de contagem por completo.

#O que the_post() e have_posts() fazem por trás do loop

Ler o class-wp-query.php explica vários bugs que parecem aleatórios nos templates.

  • Na primeira chamada a the_post(), o core prepara as caches de autores para todo o conjunto de resultados e dispara a ação loop_start. Se a consulta não devolveu objetos de post completos, também prepara nesse momento as caches de posts, meta e termos (a não ser que update_post_meta_cache ou update_post_term_cache sejam false).
  • Com 'fields' => 'ids', o core atual continua a converter cada ID num objeto de post completo com get_post() dentro de the_post(). O loop funciona, mas a lista de IDs passa a custar o mesmo que uma consulta completa.
  • setup_postdata() define mais globais do que $post: $id, $authordata, $currentday, $currentmonth, $page, $pages, $multipage, $more e $numpages, e depois dispara a ação the_post. Um loop secundário sem reset deixa todas a apontar para o último post relacionado. É por isso que uma caixa de autor ou um post paginado (<!--nextpage-->) se estraga depois de um bloco de posts relacionados.
  • Quando have_posts() fica sem posts, dispara loop_end e rebobina a consulta. Se a consulta não encontrou nada, dispara loop_no_results.

#wp_reset_postdata e a global $post

$related->the_post() define a global $post. Depois do loop tem de chamar wp_reset_postdata() para que o resto do template (sidebar, rodapé, widgets relacionados) continue a ver o post principal.

$q = new WP_Query( $args );
while ( $q->have_posts() ) {
	$q->the_post();
	// template
}
wp_reset_postdata();

#Diferença entre wp_reset_postdata e wp_reset_query

Em wp-includes/query.php a diferença são duas linhas. wp_reset_postdata() chama $wp_query->reset_postdata(), que restaura a global $post e as globais acima a partir da consulta principal. wp_reset_query() começa por repor $wp_the_query em $wp_query e depois chama wp_reset_postdata(). Só precisa de wp_reset_query() depois de query_posts(), e aí a correção é remover o query_posts(). Não o use em código novo.

Quando percorre com foreach um array devolvido por get_posts(), as template tags não sabem do post até chamar setup_postdata( $post ); faça o reset da mesma forma no fim:

global $post;
$items = get_posts( [ 'numberposts' => 3 ] );

foreach ( $items as $post ) {
    setup_postdata( $post );
    the_title( '<li>', '</li>' );
}
wp_reset_postdata();

setup_postdata() não atribui a global $post por si, e é por isso que a variável do ciclo aqui é a própria global $post. Em alternativa, evite template tags globais e leia $post->post_title diretamente.

Se esquecer o reset, get_the_ID() devolve o post errado no rodapé, em blocos de schema e em módulos «leia também» que esperam o post principal.

#Encontrar consultas lentas com Query Monitor

Query Monitor (John Blackbourn) mostra SQL, call stack e que componente é dono da consulta. Instale-o em staging, nunca em produção. Fluxo de trabalho que funciona:

  1. Abra a página que parece lenta (arquivo, lista de produtos, home com três blocos «destaque»).
  2. Veja o separador Queries e ordene por tempo. A consulta mais longa é quase sempre a que tem de corrigir. O agrupamento por componente mostra logo se o TTFB de 2 segundos vem do seu tema, do core do WooCommerce ou de um plugin de terceiros ligado a pre_get_posts.
  3. Veja Duplicate Queries: apanha loops N+1 em que uma template tag dentro de the_post() volta a consultar a base de dados por linha, e muitas vezes o mesmo tax_query no header, no hero e na lista principal.
  4. Veja Hooks: confirme que o seu callback de pre_get_posts corre mesmo na consulta principal.

Mais três ferramentas que encontram a resposta:

  • SAVEQUERIES. Defina define( 'SAVEQUERIES', true ); no wp-config.php, só em staging, porque todas as consultas do pedido ficam em memória. O WordPress preenche $wpdb->queries; cada entrada guarda o SQL, a duração, a pilha de chamadas, a hora de início e dados próprios opcionais (wpdb::log_query() no core). error_log( print_r( $wpdb->queries, true ) ) em shutdown dá o registo canónico do que correu e quanto demorou.
  • $query->request mais EXPLAIN. Depois de new WP_Query(), echo $query->request; imprime o SQL que o WordPress gerou de facto. Corra essa string com EXPLAIN no TablePlus ou com mysql -e. Procure type: ALL (scan completo da tabela), Using filesort ou Using temporary: são essas as linhas que precisam de índice.
  • WP-CLI. wp db query --skip-column-names "EXPLAIN $SQL" corre o EXPLAIN contra a base de dados real sem o peso do browser. wp post list --post_type=product --fields=ID --posts_per_page=10 reproduz a lógica da consulta num ambiente determinístico, útil quando a página lenta está atrás de paywall ou de autenticação.

Uma heurística para priorizar: com a cache quente, qualquer consulta acima de 50 ms é suspeita e acima de 200 ms está partida. O web.dev considera bom um TTFB de 0,8 segundos ou menos; se um único WP_Query come 250 ms disso, uma boa parte do orçamento desaparece antes de o template começar a renderizar. O TTFB não é em si um Core Web Vital, mas é a primeira parte do tempo de LCP, por isso cada milissegundo dele aparece no LCP.

Em produção, com memory_limit apertado e poucos workers php-fpm, dez WP_Query extra na home multiplicam-se sob tráfego de campanha. Meça em local ou staging e use um APM em produção, se o tiver.

#Object cache e WP_Query

Desde o WordPress 6.1, o core já guarda no object cache a lista de IDs de consultas idênticas repetidas (cache_results). Além disso, o WP_Query prepara as caches de post, meta e termos para os IDs que obtém. Com Redis ou Memcached como object cache estas caches persistem entre pedidos e lookups repetidos de get_post() / meta ficam mais baratos, mas o primeiro SQL de uma combinação única de argumentos continua a ir ao MySQL. Sem object cache persistente, nada disto sobrevive ao fim do pedido: a preparação das caches repete-se em cada pedido.

Padrões práticos para o que essa cache não cobre:

  • Transient à volta de listas de IDs. Faça cache do array de IDs de uma consulta cara e invalide em save_post para esse post type. Não guarde o objeto WP_Query inteiro: serializa todos os posts e o estado da consulta, e a cópia em cache já não se comporta como uma consulta nova.
  • Não faça cache de HTML completo no mesmo transient que a lista de IDs se o conteúdo for personalizado ou tiver partes dependentes de nonce.
  • Sticky posts e ordem post__in. Quando fizer cache de IDs, lembre-se de orderby => 'post__in' na reidratação.
$cache_key = 'home_trending_jobs_v1';
$ids       = get_transient( $cache_key );

if ( false === $ids ) {
    $q = new WP_Query( [
        /* Argumentos complexos */
        'fields'        => 'ids',
        'no_found_rows' => true,
    ] );
    $ids = $q->posts;
    set_transient( $cache_key, $ids, HOUR_IN_SECONDS );
}

$trending = new WP_Query( [
    'post__in'      => $ids ?: [ 0 ],
    'orderby'       => 'post__in',
    'no_found_rows' => true,
] );

Sem object cache persistente, os transients ficam em wp_options. Em alojamento económico sem Redis: TTLs curtos, poucas chaves, e limpe transients mortos. Com Redis: use um prefixo de chave por ambiente (prod:, staging:) para a cache não vazar entre slots de deploy.

#Desligar a preparação de cache numa consulta

Depois de obter os IDs, o WP_Query chama update_postmeta_cache() e update_object_term_cache() para carregar de uma vez todas as linhas de meta e de termos desses posts para o object cache. É a omissão certa para um loop de template que vai chamar get_post_meta() e get_the_terms(). É desperdício num loop que só mostra título e permalink.

$args = [
    'update_post_meta_cache' => false,
    'update_post_term_cache' => false,
    'no_found_rows'          => true,
    'fields'                 => 'ids',
];

Num arquivo de 50 posts com 30 linhas de meta cada, saltar a preparação da cache de meta evita ler e guardar 1.500 linhas num único pedido. Junto com 'fields' => 'ids', evita hidratar objetos WP_Post por completo. Serve para geradores de sitemap, listas de IDs de posts relacionados na sidebar e widgets do painel de administração, desde que leia $q->posts diretamente e não corra the_post() sobre ele.

Full page cache (nginx FastCGI cache, Cloudflare, ou cache de HTML por plugin) fica à frente do php-fpm. Um WP_Query barato continua a ajudar em cache miss, preview, utilizadores autenticados e páginas de carrinho WooCommerce onde o HTML não é cacheado. Otimize a consulta na mesma; não confie só no edge cache.

#Quando o WP_Query divide a consulta em IDs e objetos

O core nem sempre vai buscar as linhas completas num só SELECT. Em WP_Query::get_posts(), quando o SQL não foi alterado por filtros posts_* e há um object cache externo ativo, ou a consulta tem LIMIT com posts_per_page abaixo de 500, o WordPress seleciona primeiro só os IDs e depois preenche os objetos de post, a partir do object cache onde já estiverem. O filtro split_the_query liga ou desliga este comportamento.

Duas consequências para o debug:

  • O SQL no Query Monitor pode ser SELECT wp_posts.ID ... seguido de uma leitura separada dos posts em falta. É comportamento do core, não um bug de plugin.
  • Qualquer plugin que altere o SQL através dos filtros de cláusulas posts_* desliga a divisão. Um plugin que altere os campos selecionados (posts_fields, posts_fields_request, posts_clauses_request) também desliga a query cache do 6.1 nesse pedido.

Há ainda um atalho completo: o filtro posts_pre_query corre antes da chamada à base de dados e, se devolver um array, o WordPress salta o SQL por inteiro. Os plugins de pesquisa usam-no para responder ao WP_Query a partir de um índice externo. Se o Query Monitor mostra um WP_Query com resultados e sem SQL correspondente, procure um callback em posts_pre_query.

#Quando usar get_posts em vez de WP_Query

get_posts() é um wrapper fino sobre WP_Query. Define sempre no_found_rows => true e ignore_sticky_posts => true, e por omissão usa suppress_filters => true. Atenção ao que isto significa: pre_get_posts dispara sempre, mesmo com suppress_filters => true. O que fica desligado são os filtros SQL posts_* (posts_where, posts_join, posts_clauses, the_posts, etc.). Use-o quando:

  • precisa de um array de posts ou IDs,
  • não precisa do loop have_posts(),
  • não pagina.
$ids = get_posts(
	[
		'post_type'              => 'post',
		'posts_per_page'         => 5,
		'fields'                 => 'ids',
		'no_found_rows'          => true,
		'update_post_meta_cache' => false,
		'update_post_term_cache' => false,
	]
);

Escolha new WP_Query() quando precisa de found_posts, max_num_pages, paginação própria, ou controlo explícito sobre filtros. Escolha pre_get_posts quando altera o arquivo em que o utilizador já está. Escolha get_posts() para listas auxiliares pequenas e sem paginação em PHP.

A REST API (/wp/v2/posts) e o Query Loop do editor de blocos são caminhos próprios. Também acabam em WP_Query, mas os seus filtros em pre_get_posts têm de respeitar is_admin(), o contexto REST e WP_Query::is_main_query() para não alterar respostas da API por acidente.

WP_Query é síncrono no pedido. Evite loops secundários pesados em wp_footer «porque é prático». Coloque-os no sítio do template onde o HTML é mesmo necessário, ou carregue via REST/Ajax se o bloco está abaixo da dobra.

#Alterar os resultados do WP_Query na REST API

Se está a construir um frontend React com WordPress headless, não usa WP_Query diretamente em JS, mas a REST API usa-o no backend.

#Filtrar os argumentos de consulta da REST API

O filtro rest_{post_type}_query permite alterar a forma como a API consulta a base de dados com base nos parâmetros do URL. Verifique primeiro o endpoint: /wp/v2/posts já aceita um parâmetro nativo exclude, por isso um parâmetro próprio só faz sentido para lógica que o core não cobre.

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() converte uma string separada por vírgulas num array de inteiros positivos, por isso um parâmetro manipulado não consegue injetar mais nada na consulta.

#Checklist de WP_Query antes do merge

Quando uma página está lenta, siga a lista por ordem: os três primeiros pontos explicam a maior parte da lentidão de WP_Query em sites WordPress em produção.

  1. Corra o Query Monitor no URL lento e ordene por tempo. A consulta mais longa é quase sempre a que tem de corrigir. Confirme também que o número de SQL não duplica na home.
  2. Verifique se algum meta_query usa um meta_key sem índice. Se sim, acrescente INDEX (meta_key(32), post_id) em wp_postmeta, ou passe o filtro para uma taxonomia.
  3. Precisa de paginação? Se não: no_found_rows => true em cada loop secundário que não mostra total. Se sim: paged / page corretos e sem offset.
  4. Substitua posts_per_page => -1 por um limite fixo, ou trabalhe em lotes com paged.
  5. É a consulta principal? Use pre_get_posts, não um loop extra nem query_posts().
  6. Proteja cada callback de pre_get_posts com is_admin() e $query->is_main_query().
  7. Loops que só mostram título e ligação: fields => 'ids' e update_post_meta_cache => false.
  8. Chame wp_reset_postdata() após cada loop secundário que use the_post() ou setup_postdata().
  9. Se o mesmo WP_Query corre em cada carregamento e os dados mudam pouco, guarde os IDs num transient com a chave baseada na assinatura dos argumentos. A query cache do 6.1 ajuda com object cache persistente, mas não substitui esta decisão.

Leitura interna: como acelerar um site baseado em WordPress. Externa: a referência oficial de WP_Query, pre_get_posts e advanced administration: performance.

Próximo passo

Transforme o artigo numa implementação real

Este bloco reforça a ligação interna e conduz o leitor para o passo seguinte mais útil dentro da arquitetura do site.

Quer implementar isto no seu site?

Se o problema está nos Core Web Vitals, no rendering lento ou no peso do WordPress, posso mapear e implementar a otimização.

Explorar outros serviços WordPress e base de conhecimento

Reforce o seu negócio com suporte técnico profissional em áreas-chave do ecossistema WordPress.

Perguntas frequentes

Respostas práticas para aplicar o tema na execução real.

O que é O guia definitivo para wp_Query e the loop (edição 2026)?#
É um guia prático de WP_Query e The Loop em PHP 8.4: loop principal vs secundário, porque `query_posts()` é proibido, e como argumentos errados geram N+1 e matam TTFB em listagens.

Precisa de FAQ adaptado ao setor e mercado? Crio uma versão alinhada com os seus objetivos de negócio.

Fale connosco

Artigos Relacionados