WP_Query é a classe que o WordPress usa para ir buscar conteúdos à base de dados. 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. O erro quase sempre é o mesmo: demasiadas consultas, argumentos demasiado largos, ou a variável de paginação errada.
Em alojamento partilhado português (WebHS, PTISP, ou uma VPS própria com nginx + 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, paginação, wp_reset_postdata(), Query Monitor, object cache e quando get_posts() chega.
Referência oficial: WP_Query, pre_get_posts e otimização de performance.
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();
}Nunca use query_posts(). Sobrescreve a consulta principal a meio do pedido, parte a paginação e as condicionais, e força um hit extra à base de dados. 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().
Num tema de loja WooCommerce visto em staging em Lisboa, o arquivo de produtos já era a consulta principal. Alguém tinha colocado um segundo WP_Query em archive-product.php «para filtrar a categoria destaque». Resultado: duas listagens, paginação a apontar para o sítio errado, e o Query Monitor a marcar o dobro de SQL em cada pedido.
Alterar a consulta principal com pre_get_posts
pre_get_posts corre antes do SQL ser montado. Obtém um único hit à base de dados em vez de «ir buscar tudo e filtrar em PHP» ou «correr 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' );
}
}
);Três regras que poupam horas de debug:
- Proteja com
is_admin()e$query->is_main_query(). Sem isto atinge REST, feeds, listagens de admin e cada loop secundário. - Defina argumentos com
$query->set(), não substituindo o objeto inteiro. - Não chame
get_posts()nemnew WP_Query()dentro do callback sem justificação forte. 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
Por omissão, WP_Query corre uma consulta que também calcula o total de resultados para paginação (FOUND_ROWS() / historicamente SQL_CALC_FOUND_ROWS). Se não precisa de números de página, 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,
]
);Quando só precisa de IDs (relacionados, listas de exclusão, chaves de transient), fields => 'ids' é a opção mais barata: menos dados na rede, menos hidratação de 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.
posts_per_page => -1 quase sempre está errado em produção. Defina um número. Se realmente precisa de todos os IDs (migração, CLI), faça-o em WP-CLI ou num job em background, não num pedido HTTP atrás de php-fpm.
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 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 a versão e 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 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.
Num projeto B2B com filtro «distrito» em postmeta, a listagem abria em 1,8 s de TTFB. Depois de migrar o filtro para uma taxonomia distrito e tax_query com term_id, o mesmo arquivo ficou sob 400 ms de SQL na mesma máquina MariaDB. O custo não era PHP; era o JOIN em wp_postmeta sem índice útil para aquele meta_key.
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. 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.
Quando o loop não deve paginar, defina no_found_rows => true e salte paginate_links(). Poupa a consulta de contagem por completo.
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();wp_reset_query() é da era do query_posts(). Não o use em código novo. Se iterar um array de WP_Post sem the_post(), use setup_postdata( $post ) dentro do loop e wp_reset_postdata() depois, ou evite template tags globais e leia $post->post_title diretamente.
Se esquecer o reset, get_the_ID() falha no rodapé, em blocos de schema e em módulos «leia também» que esperam o post principal. Num tema filho usado por uma equipa de conteúdo no Porto, o JSON-LD do artigo apontava para o último post do bloco «relacionados» porque o reset tinha sido omitido após um refactor.
Encontrar consultas lentas com Query Monitor
Query Monitor mostra SQL, call stack e que componente é dono da consulta. Fluxo de trabalho que funciona em staging:
- Abra a página que parece lenta (arquivo, lista de produtos, home com três blocos «destaque»).
- Veja o separador Queries: ordene por tempo. Encontre linhas
WP_Querycom duração alta ou muitas consultas iguais. - Veja Duplicate Queries: muitas vezes é o mesmo
tax_queryno header, no hero e na lista principal. - Veja Hooks: confirme que o seu callback de
pre_get_postscorre mesmo na consulta principal.
Em alojamento partilhado em Portugal costuma haver memory_limit apertado e poucos workers php-fpm. Dez WP_Query extra na home multiplicam-se sob tráfego de campanha. Query Monitor em local ou staging dá números; em produção use New Relic / Blackfire se tiver, ou QM temporário atrás de restrição por IP.
Object cache e WP_Query
WP_Query não guarda automaticamente o conjunto completo de resultados entre pedidos. Aquece caches de post, meta e termo para os IDs que obtém. Com Redis ou Memcached como object cache (comum em WordPress gerido e VPS própria) 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.
Padrões práticos:
- Transient à volta de listas de IDs. Faça cache do array de IDs de um
tax_querycaro, TTL 5-15 minutos, invalide emsave_postpara esse post type. - 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.
Sem object cache, os transients ficam em wp_options (autoload pode tornar-se um problema). 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 o cache não vazar entre slots de deploy.
Full page cache (nginx FastCGI cache, Cloudflare, ou HTML cache 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 usar get_posts em vez de WP_Query
get_posts() é um wrapper fino sobre WP_Query com alguns valores por omissão (suppress_filters historicamente, comportamento semelhante a no_found_rows em chamadas simples). 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(), 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.
Checklist de WP_Query antes do merge
- É a consulta principal? Use
pre_get_posts, não um loop extra. - Precisa de paginação? Se não:
no_found_rows => true. Se sim:paged/pagecorretos. - Só precisa de IDs?
fields => 'ids'. - O filtro é taxonomia, não meta?
- Chame
wp_reset_postdata()após cada loop secundário que usethe_post(). - Confirme com Query Monitor que o número de SQL não duplica na home.
- Com object cache: faça cache de listas de IDs, não da assunção «o WordPress faz cache da consulta por mim».
Leitura interna: como acelerar um site baseado em WordPress. Externa: a referência oficial de WP_Query, pre_get_posts e advanced administration: performance.






