🔁 The WordPress Loop
The Loop is the beating heart of every WordPress theme. It's the handful of lines that walk through the posts WordPress fetched from the database and hand each one to your template tags. Learn it once and you'll recognize it in every theme you ever open.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain the role of
have_posts(),the_post(), and template tags in the Loop - Write the standard Loop with a proper no-results fallback
- Adapt the Loop for single posts, archives, and search results
- Build a secondary loop with
WP_Queryand reset it withwp_reset_postdata() - Modify the main query the right way with
pre_get_postsinstead ofquery_posts()
Estimated Time: 40–50 minutes • Difficulty: Intermediate
Hands-on: Write a "related posts" secondary loop, then fix a deliberately broken Loop.
In This Lesson
What the Loop Does
Before a single line of your template runs, WordPress has already looked at the URL, run a database query, and stashed the matching posts in a global object called $wp_query. The Loop is the code that iterates over those results and displays each post. Without it, WordPress would just be a database with no way to show its contents.
💡 A useful analogy: Picture a factory conveyor belt. WordPress loads the belt with products (posts). The Loop moves the belt one item at a time; at each stop, template tags stamp on the title, attach the content, and add the byline. When the belt is empty, the Loop stops and the finished page rolls out.
The Loop cycles until have_posts() returns false.
Anatomy of the Loop
Here is the canonical Loop. Every WordPress theme contains some version of it:
<?php
if ( have_posts() ) :
while ( have_posts() ) :
the_post();
?>
<article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>
<h2>
<a href="<?php the_permalink(); ?>"><?php the_title(); ?></a>
</h2>
<div class="entry-content">
<?php the_content(); ?>
</div>
</article>
<?php
endwhile;
else :
?>
<p><?php esc_html_e( 'Sorry, no posts matched your criteria.', 'my-theme' ); ?></p>
<?php
endif;
?>
Four pieces do all the work:
📖 The four moving parts
have_posts() — Returns true while posts remain in the query. It's the "is there anything left on the belt?" check that both the if and the while use.
the_post() — Advances to the next post and sets up its data as the global "current post," so the template tags below know which post they refer to.
Template tags — Functions like the_title(), the_content(), and the_permalink() that echo data about the current post.
The else branch — Runs when there are zero posts, giving visitors a graceful "nothing found" message.
⚠️ The most common beginner mistake
Calling a template tag like the_title() outside the Loop returns data for the wrong post — or nothing at all. Template tags only know which post they mean after the_post() has set it up. Keep them inside the while.
The alternate PHP syntax (if ( … ) : … endif;) shown above is preferred in templates because it interleaves cleanly with HTML. You'll also see the compact one-line form while ( have_posts() ) : the_post(); in production themes — it means exactly the same thing.
The Loop in Different Contexts
The beauty of the Loop is that its structure never changes, but the posts flowing through it do — WordPress prepares the right query for each context automatically. Your job is just to display what arrives.
Archive / blog index — a list of summaries
On archives and the blog index you typically show excerpts and a pagination control:
<?php
if ( have_posts() ) :
while ( have_posts() ) :
the_post();
?>
<article>
<h2><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
<p class="meta">
<?php printf(
/* translators: %1$s date, %2$s author */
esc_html__( 'Posted on %1$s by %2$s', 'my-theme' ),
get_the_date(),
get_the_author()
); ?>
</p>
<div class="excerpt"><?php the_excerpt(); ?></div>
</article>
<?php
endwhile;
the_posts_pagination();
else :
?>
<p><?php esc_html_e( 'No posts found.', 'my-theme' ); ?></p>
<?php
endif;
?>
Single post — one full post
On a single post the query holds exactly one post, so the Loop runs once. You show the full content and often the comments:
<?php
while ( have_posts() ) :
the_post();
?>
<article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>
<h1><?php the_title(); ?></h1>
<?php if ( has_post_thumbnail() ) : ?>
<figure class="featured-image"><?php the_post_thumbnail( 'large' ); ?></figure>
<?php endif; ?>
<div class="entry-content"><?php the_content(); ?></div>
<footer><?php the_tags( 'Tags: ', ', ' ); ?></footer>
</article>
<?php
if ( comments_open() || get_comments_number() ) {
comments_template();
}
endwhile;
?>
Even for a single post you still write the while loop. It runs once, but keeping the structure means the_post() is always called to set up the post data.
Search results — the same Loop, richer context
Search results reuse the identical pattern, adding the query term and result count:
<h1>
<?php printf( esc_html__( 'Results for: %s', 'my-theme' ), '<span>' . esc_html( get_search_query() ) . '</span>' ); ?>
</h1>
<?php if ( have_posts() ) : ?>
<p><?php printf( esc_html__( 'Found %d result(s).', 'my-theme' ), (int) $GLOBALS['wp_query']->found_posts ); ?></p>
<?php
while ( have_posts() ) :
the_post();
?>
<article>
<h2><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
<div class="excerpt"><?php the_excerpt(); ?></div>
</article>
<?php
endwhile;
the_posts_pagination();
else :
?>
<p><?php esc_html_e( 'No results. Try a different search.', 'my-theme' ); ?></p>
<?php get_search_form(); ?>
<?php endif; ?>
💡 One pattern, many pages
Notice the have_posts() / the_post() skeleton is byte-for-byte identical across all three contexts. Only the surrounding HTML and which tags you call change. Learn the skeleton once and you've learned it everywhere.
Secondary Loops with WP_Query
The main Loop shows whatever WordPress decided to fetch for the current URL. But often you want an additional list — "featured posts," "recent posts in the sidebar," "related articles." For that you create a secondary loop with a fresh WP_Query.
💡 Back to the factory: the main conveyor belt handles the standard product line. A secondary loop is a smaller extra belt you switch on for a special batch — and switch off cleanly when you're done.
<?php
$featured = new WP_Query( array(
'post_type' => 'post',
'posts_per_page' => 3,
'meta_key' => 'is_featured',
'meta_value' => 'yes',
) );
if ( $featured->have_posts() ) : ?>
<section class="featured">
<h2>Featured</h2>
<?php while ( $featured->have_posts() ) : $featured->the_post(); ?>
<article>
<h3><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h3>
<?php the_excerpt(); ?>
</article>
<?php endwhile; ?>
</section>
<?php
wp_reset_postdata(); // ← restore the main query's current post
endif;
?>
⚠️ Always call wp_reset_postdata()
A secondary loop calls $featured->the_post(), which hijacks the global "current post." If you forget wp_reset_postdata() afterward, the rest of your page — pagination, the main Loop's tags, the comment form — will operate on the wrong post. This single omission causes more WordPress bugs than almost anything else.
Common WP_Query parameters
| Group | Useful keys |
|---|---|
| Basics | post_type, posts_per_page, orderby, order |
| Selection | post__in, post__not_in, name, p |
| Taxonomy | category_name, tag, tax_query |
| Date / author | date_query, author, author_name |
| Custom fields | meta_key, meta_value, meta_query |
Worked example: related posts by category
A classic use of a secondary loop is showing related articles under a single post:
<?php
$categories = get_the_category();
if ( $categories ) :
$ids = wp_list_pluck( $categories, 'term_id' );
$related = new WP_Query( array(
'category__in' => $ids,
'post__not_in' => array( get_the_ID() ), // exclude the current post
'posts_per_page' => 3,
'orderby' => 'rand',
'ignore_sticky_posts' => true,
) );
if ( $related->have_posts() ) : ?>
<section class="related">
<h2>Related posts</h2>
<div class="grid">
<?php while ( $related->have_posts() ) : $related->the_post(); ?>
<article>
<?php if ( has_post_thumbnail() ) : ?>
<a href="<?php the_permalink(); ?>"><?php the_post_thumbnail( 'thumbnail' ); ?></a>
<?php endif; ?>
<h3><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h3>
</article>
<?php endwhile; ?>
</div>
</section>
<?php
wp_reset_postdata();
endif;
endif;
?>
💡 get_posts() for simple lists
When you only need an array of posts and no template-tag Loop, get_posts() is a lighter option — it returns the posts directly. But if you use it with setup_postdata() to enable template tags, you must still call wp_reset_postdata() afterward.
Modifying the Main Query
Sometimes you don't want a second loop — you want to change what the main Loop fetches. For example: show 12 posts per category page, or exclude a category from the blog index. The right tool is the pre_get_posts action, placed in functions.php.
<?php
function mytheme_adjust_main_query( $query ) {
// Only touch the main query on the front end.
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( $query->is_category() ) {
$query->set( 'posts_per_page', 12 );
}
if ( $query->is_home() ) {
$query->set( 'category__not_in', array( 5 ) ); // hide category 5 from the blog
}
if ( $query->is_search() ) {
$query->set( 'post_type', array( 'post', 'page' ) );
}
}
add_action( 'pre_get_posts', 'mytheme_adjust_main_query' );
⚠️ Never use query_posts()
The old query_posts() function replaces the main query, which breaks pagination and forces a second database query. It's effectively deprecated for theme use. To reshape the main query, always use pre_get_posts; to add an independent list, use WP_Query. The two is_admin() and is_main_query() guards above are essential — without them you'd accidentally alter admin screens and every secondary query too.
Pick the right query tool for the job.
Hands-on Exercise
🏋️ Part A — Write a "recent posts" sidebar loop
Objective: Add a secondary loop that lists the five most recent posts, correctly reset.
- Create a
WP_Queryforpost_type => 'post',posts_per_page => 5. - Loop through it, outputting each title as a link and its date.
- Call
wp_reset_postdata()when done.
💡 Hint
Store the query in a variable like $recent and call its methods as $recent->have_posts() and $recent->the_post() — not the bare global functions.
✅ Solution
<?php
$recent = new WP_Query( array(
'post_type' => 'post',
'posts_per_page' => 5,
'ignore_sticky_posts' => true,
) );
if ( $recent->have_posts() ) : ?>
<ul class="recent-posts">
<?php while ( $recent->have_posts() ) : $recent->the_post(); ?>
<li>
<a href="<?php the_permalink(); ?>"><?php the_title(); ?></a>
<time><?php echo esc_html( get_the_date() ); ?></time>
</li>
<?php endwhile; ?>
</ul>
<?php wp_reset_postdata();
endif;
?>
🏋️ Part B — Fix the broken Loop
Objective: Spot and fix two bugs in this snippet.
<?php
$q = new WP_Query( array( 'posts_per_page' => 4 ) );
while ( $q->have_posts() ) : the_post();
the_title( '<h3>', '</h3>' );
endwhile;
// main loop continues below…
✅ Solution
Bug 1: the loop calls the global the_post() instead of $q->the_post(), so it never advances the custom query correctly. Bug 2: there's no wp_reset_postdata(), so the main loop below will show the wrong post. Fixed:
<?php
$q = new WP_Query( array( 'posts_per_page' => 4 ) );
while ( $q->have_posts() ) : $q->the_post();
the_title( '<h3>', '</h3>' );
endwhile;
wp_reset_postdata();
🎯 Quick Quiz
Question 1: What is the job of the_post() inside the Loop?
Question 2: After running a secondary loop with WP_Query, which function must you call?
Question 3: You want the category archive to show 12 posts per page instead of the default. What's the correct approach?
Best Practices & Pitfalls
✅ Do
- Always reset with
wp_reset_postdata()after a secondary loop. - Always provide an
else/ no-results branch. - Reshape the main query with
pre_get_posts, guarded byis_admin()andis_main_query(). - Escape dynamic output and move repeated markup into template parts.
- Limit
posts_per_pageand cache heavy custom queries with the Transients API.
⚠️ Avoid
query_posts()— it breaks pagination and the main query.- Calling template tags outside the Loop (they target the wrong post).
- Forgetting the reset — the number-one cause of "wrong post" bugs.
- Hard-coding IDs or category names that differ between environments.
📖 Caching an expensive query
function mytheme_popular_ids() {
$ids = get_transient( 'mytheme_popular_ids' );
if ( false === $ids ) {
$ids = get_posts( array(
'fields' => 'ids',
'posts_per_page' => 5,
'meta_key' => 'view_count',
'orderby' => 'meta_value_num',
'order' => 'DESC',
) );
set_transient( 'mytheme_popular_ids', $ids, HOUR_IN_SECONDS );
}
return $ids;
}
Summary & Quiz
🎉 Key Takeaways
- The Loop iterates the posts WordPress already queried:
have_posts()checks,the_post()advances, template tags display. - The skeleton is identical across archives, single posts, and search — only the markup changes.
- Use
get_the_*when you need a value to manipulate;the_*to echo it. - For extra lists, build a
WP_Querysecondary loop and always callwp_reset_postdata(). - To change the main query, hook
pre_get_posts— neverquery_posts().
📚 Further Reading
🚀 What's Next?
You can now pull content out of the database and display it. Next you'll let users reshape the theme without code, using the Theme Customization API — colors, fonts, and layouts with a live preview.
🎉 Loop mastered!
You'll spot this pattern in every theme from here on out.