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:
- Analisa os argumentos: converte o seu array de argumentos num formato normalizado.
- Gera o SQL: monta uma instrução SQL
SELECT. - Executa a consulta: envia o pedido à base de dados.
- Preenche o objeto: carrega o objeto
WP_Querycom 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 objetosWP_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õefound_postsemax_num_pagese suporta iteração comthe_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 objetosWP_Post. Por omissão usa'suppress_filters' => truee define sempre'no_found_rows' => truee'ignore_sticky_posts' => true. É mais rápido, mas salta os filtrosposts_*. Use-o para listas curtas e de tamanho fixo na sidebar.query_posts(): destrutivo. Substitui a global$wp_querypor uma nova consulta, parte as condicionaisis_*()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 porpre_get_postspara a consulta principal e pornew WP_Querypara 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:
- Chame as condicionais como métodos do objeto recebido (
$query->is_home()), não como funções globais. A função globalis_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. - 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. 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.- Defina argumentos com
$query->set(), sem substituir o objeto inteiro. Nunca chamequery_posts()nem um segundonew 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
pagednum 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:
- Vá buscar os IDs dos últimos 50 posts.
- Escolha 5 IDs ao acaso em PHP.
- 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:
pagedpara arquivos, índice do blog e a maioria das listas personalizadas.pagepara 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çãoloop_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 queupdate_post_meta_cacheouupdate_post_term_cachesejamfalse). - Com
'fields' => 'ids', o core atual continua a converter cada ID num objeto de post completo comget_post()dentro dethe_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,$moree$numpages, e depois dispara a açãothe_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, disparaloop_ende rebobina a consulta. Se a consulta não encontrou nada, disparaloop_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:
- Abra a página que parece lenta (arquivo, lista de produtos, home com três blocos «destaque»).
- 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. - 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 mesmotax_queryno header, no hero e na lista principal. - Veja Hooks: confirme que o seu callback de
pre_get_postscorre mesmo na consulta principal.
Mais três ferramentas que encontram a resposta:
SAVEQUERIES. Definadefine( 'SAVEQUERIES', true );nowp-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 ) )emshutdowndá o registo canónico do que correu e quanto demorou.$query->requestmaisEXPLAIN. Depois denew WP_Query(),echo $query->request;imprime o SQL que o WordPress gerou de facto. Corra essa string comEXPLAINno TablePlus ou commysql -e. Procuretype: ALL(scan completo da tabela),Using filesortouUsing temporary: são essas as linhas que precisam de índice.- WP-CLI.
wp db query --skip-column-names "EXPLAIN $SQL"corre oEXPLAINcontra a base de dados real sem o peso do browser.wp post list --post_type=product --fields=ID --posts_per_page=10reproduz 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_postpara esse post type. Não guarde o objetoWP_Queryinteiro: 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 deorderby => '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.
- 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.
- Verifique se algum
meta_queryusa ummeta_keysem índice. Se sim, acrescenteINDEX (meta_key(32), post_id)emwp_postmeta, ou passe o filtro para uma taxonomia. - Precisa de paginação? Se não:
no_found_rows => trueem cada loop secundário que não mostra total. Se sim:paged/pagecorretos e semoffset. - Substitua
posts_per_page => -1por um limite fixo, ou trabalhe em lotes compaged. - É a consulta principal? Use
pre_get_posts, não um loop extra nemquery_posts(). - Proteja cada callback de
pre_get_postscomis_admin()e$query->is_main_query(). - Loops que só mostram título e ligação:
fields => 'ids'eupdate_post_meta_cache => false. - Chame
wp_reset_postdata()após cada loop secundário que usethe_post()ousetup_postdata(). - Se o mesmo
WP_Querycorre 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.






