You add three related posts to a single-post template. Suddenly the comment area below shows another post’s discussion and the breadcrumb points somewhere else entirely. Stare at the template all you like — nothing there is wrong. The cause is a missing line.
the_post() mutates global state
WordPress template tags such as the_title(), the_permalink() and get_the_ID() take no arguments. They read the global $post instead. And what the_post() does inside a loop is precisely to point that global at the next item.
Run a secondary query and its the_post() touches the very same global. When the loop ends, the global is left pointing at the last item of the sub-loop. Unless you restore it, everything below renders the wrong post — and it presents itself as “the template is broken”.
wp_reset_postdata() restores the global to the main query’s current post. Put it immediately after the sub-loop, as a matched pair you always type together.
<?php
$related = new WP_Query( [
'post_type' => 'post',
'posts_per_page' => 3,
'post__not_in' => [ get_the_ID() ],
'no_found_rows' => true,
] );
if ( $related->have_posts() ) :
while ( $related->have_posts() ) :
$related->the_post();
get_template_part( 'template-parts/card', 'note' );
endwhile;
wp_reset_postdata(); // without this line, everything below drifts
endif;
Do not use query_posts()
There is a similar-looking function to avoid. query_posts() does not create a secondary query — it replaces the main one. Pagination breaks, conditional tags like is_single() start lying, and the database is queried an extra time for nothing.
To change which posts a listing shows, the answer is pre_get_posts. It modifies the main query before it runs, so there is no extra query and the conditional tags stay honest. The guard is mandatory.
<?php
add_action( 'pre_get_posts', function ( $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return; // without this, admin lists and sub-queries change too
}
if ( $query->is_category() ) {
$query->set( 'posts_per_page', 12 );
}
} );
Arguments that keep a secondary query cheap
A secondary query runs on every render, so leaving the defaults means paying for work you do not use. Switch off what you do not need.
posts_per_page => -1 is the dangerous one. During development there are ten posts and it behaves perfectly; it only slows down in production, once content has accumulated. Setting a ceiling makes the worst case predictable.
For the same reason, avoid querying inside a loop. One extra query per item means the cost multiplies with the length of the list rather than adding to it.
More on queries and performance sits in the Performance archive, and the order in which we narrow down causes on a real site is published on our process page.
Next part
That was the building half of the series. The remaining two parts are about taking things away. Next: removing parent theme assets you do not use, and the responsibility that comes with it.