WordPress Loop in Templates: Practical Patterns and Examples

Introduction

The Loop is the single most important pattern in WordPress theming. Almost every page you build, from the blog index to a single post to a custom archive, runs some version of it. If you understand the conceptual Loop already, the next step is knowing which variation to reach for in each template file.

This article is a practical, copy-and-adapt reference. Instead of explaining what the Loop is one more time, it shows you the exact pattern to use for posts, pages, archives, custom queries, and multiple loops on the same page. Each pattern is short, complete, and ready to drop into a real theme.

By the end you will be able to:

  • Write the standard Loop for the blog index and archive templates
  • Adapt the Loop for single posts and pages
  • Build custom queries with WP_Query and reset them safely
  • Run more than one loop on a single page without breaking the others
  • Add pagination, handle empty results, and avoid the classic Loop mistakes

This builds directly on the conceptual explanation of the Loop. If the terms have_posts() and the_post() are new to you, read The WordPress Loop Explained first, then come back here for the patterns.

Quick Refresher: The Four Lines That Drive Every Loop

Every Loop variation is built from the same four moving parts. Keep this skeleton in your head and the rest is just variation.

<?php
if ( have_posts() ) :
    while ( have_posts() ) :
        the_post();
        // Output the current post here.
    endwhile;
else :
    // Run this when there are no posts.
endif;
?>

Here is what each part does:

  • have_posts() asks the current query whether there are any posts left to show. It controls both the if and the while.
  • the_post() advances to the next post and sets up the global $post object, so template tags like the_title() know which post to output.
  • The while block runs once per post. This is where your markup lives.
  • The else block runs only when the query returned nothing, so you can show a friendly "no results" message.

Template tag is the term for functions like the_title(), the_content(), and the_permalink() that print data about the current post. They only work after the_post() has run, because they read from the global $post object.

Pattern 1: The Standard Loop for the Blog Index and Home

This is the default Loop that runs on your main blog listing (home.php or index.php). It uses the main query, the one WordPress builds automatically from the URL, so you do not create a query yourself.

<?php get_header(); ?>

<main class="site-main">

    <?php if ( have_posts() ) : ?>

        <?php while ( have_posts() ) : the_post(); ?>

            <article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>
                <h2 class="entry-title">
                    <a href="<?php the_permalink(); ?>"><?php the_title(); ?></a>
                </h2>
                <div class="entry-summary">
                    <?php the_excerpt(); ?>
                </div>
            </article>

        <?php endwhile; ?>

        <?php the_posts_pagination(); ?>

    <?php else : ?>

        <p><?php esc_html_e( 'Nothing found.', 'your-theme' ); ?></p>

    <?php endif; ?>

</main>

<?php get_footer(); ?>

A few details worth noting:

  • post_class() prints helpful CSS classes such as post, type-post, and category names. It makes styling individual posts far easier.
  • the_excerpt() shows a trimmed summary, which is what you usually want on a listing page. Use the_content() only when you genuinely want the full post in the list.
  • the_posts_pagination() prints next and previous page links for the main query. It only works on the main query, which is exactly what this template uses.

Pattern 2: The Loop for a Single Post (single.php)

On a single post, the main query contains exactly one post. You still run the Loop, because the_post() is what sets up the global data that template tags rely on. Skipping the Loop here is one of the most common beginner mistakes.

<?php get_header(); ?>

<main class="site-main">

    <?php while ( have_posts() ) : the_post(); ?>

        <article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>
            <h1 class="entry-title"><?php the_title(); ?></h1>

            <div class="entry-meta">
                <?php printf(
                    esc_html__( 'Posted on %s', 'your-theme' ),
                    esc_html( get_the_date() )
                ); ?>
            </div>

            <div class="entry-content">
                <?php the_content(); ?>
            </div>
        </article>

        <?php comments_template(); ?>

    <?php endwhile; ?>

</main>

<?php get_footer(); ?>

Notice there is no if ( have_posts() ) wrapper and no else branch here. On a single post, WordPress only loads single.php when a matching post exists, so the empty case cannot happen. The while loop on its own is enough.

Use the_content() (not the_excerpt()) on single templates so the reader sees the full article. the_content() also renders blocks, shortcodes, and the more tag correctly.

Pattern 3: The Loop for a Page (page.php)

A page uses the same single-item Loop as a post. The difference is which template tags you include. Pages usually do not show a date or author, and they rarely show comments.

<?php get_header(); ?>

<main class="site-main">

    <?php while ( have_posts() ) : the_post(); ?>

        <article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>
            <h1 class="entry-title"><?php the_title(); ?></h1>

            <div class="entry-content">
                <?php the_content(); ?>
            </div>
        </article>

    <?php endwhile; ?>

</main>

<?php get_footer(); ?>

The Loop itself is identical to single.php. This is the point: the Loop is one pattern, and the template tags you place inside it decide what each template looks like. The template file you put it in decides when WordPress uses it, which is governed by the template hierarchy.

Pattern 4: The Loop for Archives, Categories, and Tags

Archive templates (archive.php, category.php, tag.php, taxonomy.php) list multiple posts, just like the blog index. The Loop is the same as Pattern 1. What changes is the context, so you usually add an archive title and description.

<?php get_header(); ?>

<main class="site-main">

    <?php if ( have_posts() ) : ?>

        <header class="archive-header">
            <h1 class="archive-title"><?php the_archive_title(); ?></h1>
            <?php the_archive_description( '<div class="archive-description">', '</div>' ); ?>
        </header>

        <?php while ( have_posts() ) : the_post(); ?>

            <article <?php post_class(); ?>>
                <h2>
                    <a href="<?php the_permalink(); ?>"><?php the_title(); ?></a>
                </h2>
                <?php the_excerpt(); ?>
            </article>

        <?php endwhile; ?>

        <?php the_posts_pagination(); ?>

    <?php else : ?>

        <p><?php esc_html_e( 'No posts found in this archive.', 'your-theme' ); ?></p>

    <?php endif; ?>

</main>

<?php get_footer(); ?>

the_archive_title() and the_archive_description() automatically print the right heading for the current archive, whether that is a category name, a tag, an author, or a date. You do not need a separate template for each one unless you want different markup.

Important: do not change which posts appear in an archive by building a new WP_Query here. To change the main query (for example, show 12 posts per page on category archives), use the pre_get_posts hook instead. Editing the main query in the template breaks pagination and is covered in the modifying main queries article.

Pattern 5: A Custom Query with WP_Query

When you need posts that are not part of the main query, for example the three latest posts in a "Featured" sidebar, build your own query with WP_Query. This is a secondary loop.

<?php
$featured = new WP_Query( array(
    'post_type'      => 'post',
    'posts_per_page' => 3,
    'category_name'  => 'featured',
) );

if ( $featured->have_posts() ) : ?>

    <section class="featured-posts">
        <?php while ( $featured->have_posts() ) : $featured->the_post(); ?>

            <article <?php post_class(); ?>>
                <a href="<?php the_permalink(); ?>"><?php the_title( '<h3>', '</h3>' ); ?></a>
            </article>

        <?php endwhile; ?>
    </section>

<?php endif; ?>

<?php wp_reset_postdata(); ?>

Three things make this pattern safe and correct:

  1. Call have_posts() and the_post() on your query object ($featured->have_posts()), not the bare functions. The bare functions always talk to the main query.
  2. You can still use normal template tags like the_title() inside the loop. Because $featured->the_post() sets the global $post, the tags read from your custom query automatically.
  3. Always call wp_reset_postdata() when the custom loop finishes. This restores the global $post to the main query, so the rest of the page (and any template tags after it) keeps working.

Forgetting wp_reset_postdata() is the most common cause of "the wrong post title shows up later on the page" bugs. Make it a habit: every custom loop that calls the_post() ends with a reset.

Pattern 6: Multiple Loops on One Page

A homepage often has several loops: a hero, a featured row, and a recent-posts grid. Each gets its own WP_Query, and each ends with its own reset. The key is to keep every query in its own variable.

<?php
// Loop 1: latest three posts.
$recent = new WP_Query( array( 'posts_per_page' => 3 ) );
if ( $recent->have_posts() ) :
    while ( $recent->have_posts() ) : $recent->the_post(); ?>
        <h3><?php the_title(); ?></h3>
    <?php endwhile;
endif;
wp_reset_postdata();

// Loop 2: posts from the "news" category.
$news = new WP_Query( array(
    'category_name'  => 'news',
    'posts_per_page' => 5,
) );
if ( $news->have_posts() ) :
    while ( $news->have_posts() ) : $news->the_post(); ?>
        <li><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></li>
    <?php endwhile;
endif;
wp_reset_postdata();
?>

Because each loop uses its own object and resets afterward, the two never interfere. If you used the bare have_posts() for both, only the main query would run and you would get the same posts twice.

Pattern 7: A Lightweight List with get_posts()

Sometimes you only need an array of posts, not a full secondary loop with template tags. get_posts() returns an array of post objects and is perfect for simple lists, dropdowns, or related-post widgets.

<?php
$related = get_posts( array(
    'category__in'   => wp_get_post_categories( get_the_ID() ),
    'posts_per_page' => 4,
    'post__not_in'   => array( get_the_ID() ),
) );

if ( $related ) : ?>
    <ul class="related-posts">
        <?php foreach ( $related as $post ) : setup_postdata( $post ); ?>
            <li><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></li>
        <?php endforeach; ?>
    </ul>
    <?php wp_reset_postdata(); ?>
<?php endif; ?>

If you want to use template tags like the_title() inside the foreach, you must call setup_postdata( $post ) on each item and declare the global, then reset at the end. If you only need raw data, you can skip setup_postdata() and read properties directly, for example $post->post_title with esc_html().

When to choose which: use WP_Query when you need pagination or the full loop API, and get_posts() for a quick, finite list. The differences between get_posts(), WP_Query, and the old query_posts() are worth knowing in detail.

Pattern 8: Pagination for a Custom Query

Pagination is where custom queries trip people up. the_posts_pagination() only works for the main query. For a custom WP_Query you must pass the current page into the query and render the links yourself with paginate_links().

<?php
$paged = max( 1, get_query_var( 'paged' ), get_query_var( 'page' ) );

$query = new WP_Query( array(
    'post_type'      => 'post',
    'posts_per_page' => 6,
    'paged'          => $paged,
) );

if ( $query->have_posts() ) :
    while ( $query->have_posts() ) : $query->the_post(); ?>
        <article><?php the_title( '<h2>', '</h2>' ); ?></article>
    <?php endwhile;

    echo paginate_links( array(
        'total'   => $query->max_num_pages,
        'current' => $paged,
    ) );

endif;
wp_reset_postdata();
?>

The two pieces that make this work:

  • paged tells WP_Query which page of results to fetch. Without it, every page shows the same first set of posts.
  • max_num_pages on the query object tells paginate_links() how many pages exist. Pass it as total so the links stop at the right place.

On a static front page WordPress uses the page query var instead of paged, which is why the example reads both. This small detail fixes the common "custom query pagination does nothing on the homepage" problem.

Which Loop Approach Should You Use?

Three tools cover almost every case. Pick based on what you need, not habit.

  • The main query (bare have_posts() / the_post()). Use it on index, archive, single, and page templates. WordPress builds it for you from the URL. To change what it returns, use pre_get_posts, never a new query in the template.
  • WP_Query (a secondary loop). Use it when you need posts the main query does not provide, especially when you need pagination or the full loop API. Call have_posts() and the_post() on the object, and always finish with wp_reset_postdata().
  • get_posts(). Use it for a short, finite list such as related posts, a dropdown, or a widget. It returns a plain array, so loop it with foreach. Add setup_postdata() only if you want template tags inside the loop, then reset.

Rule of thumb: stay on the main query in template files, reach for WP_Query when you need a second listing with pagination, and use get_posts() for quick lists where pagination does not matter. The deeper trade-offs between them are covered in the dedicated comparison article.

Useful Loop Variations

Style the First Post Differently

To make the first post in a loop stand out, check the current_post counter on the query object. It starts at zero.

<?php while ( have_posts() ) : the_post();
    $featured = ( 0 === $wp_query->current_post ) ? ' is-featured' : '';
    ?>
    <article class="card<?php echo esc_attr( $featured ); ?>">
        <?php the_title( '<h2>', '</h2>' ); ?>
    </article>
<?php endwhile; ?>

Count Posts as You Go

Use a simple counter to add classes for grid columns, for example wrapping every third post.

<?php $i = 0;
while ( have_posts() ) : the_post(); $i++; ?>
    <div class="col col-<?php echo esc_attr( $i % 3 ?: 3 ); ?>">
        <?php the_title( '<h2>', '</h2>' ); ?>
    </div>
<?php endwhile; ?>

Show an Excerpt of a Set Length

the_excerpt() respects the editor-defined excerpt or auto-trims the content. To control the length yourself, use wp_trim_words() on the content.

<?php echo esc_html( wp_trim_words( get_the_content(), 25, '...' ) ); ?>

Best Practices

  • Always run the Loop on single templates. Even for one post, the_post() sets up the global data that template tags need.
  • Use the main query for archives. Change what appears with pre_get_posts, never by replacing the query inside the template.
  • Give every custom query its own variable. This keeps multiple loops independent and readable.
  • Reset after every custom loop. Call wp_reset_postdata() whenever you used the_post() or setup_postdata() on a secondary query.
  • Escape output. Wrap dynamic values in esc_html(), esc_attr(), or esc_url() when you build markup by hand. The the_* template tags handle this for you, but raw properties do not.
  • Reach for get_posts() for short, finite lists, and WP_Query when you need pagination or the full loop API.

Common Mistakes

Forgetting wp_reset_postdata()

After a custom loop, the global $post still points at the last item from that loop. Any template tag later on the page then shows the wrong data. Always reset.

Using query_posts() to Change the Main Query

query_posts() replaces the main query in place, breaks pagination, and runs an extra database query. It is effectively deprecated for this purpose. Use pre_get_posts to modify the main query, or a new WP_Query for a secondary loop.

Calling the_post() Without have_posts()

the_post() advances the internal pointer. If you call it without the have_posts() check, you can run past the end of the results and trigger errors. Always pair them.

Skipping the Loop on Single Templates

Some developers try to output a single post with get_the_title() directly and skip the Loop. Without the_post(), the global $post is not set up, so template tags return nothing or the wrong post. Run the while loop even for one post.

Mixing Up wp_reset_postdata() and wp_reset_query()

Use wp_reset_postdata() after a WP_Query secondary loop. wp_reset_query() is only needed after query_posts(), which you should avoid anyway. When in doubt, use a fresh WP_Query and wp_reset_postdata().

Troubleshooting

The wrong post title appears further down the page

Symptom: a widget or section after a custom loop shows the title of a featured post instead of the current page. Cause: a custom loop ran the_post() and never reset. Fix: add wp_reset_postdata() immediately after the custom loop ends.

My custom query ignores pagination

Symptom: page 2 of a custom archive shows the same posts as page 1. Cause: the query has no paged argument. Fix: read get_query_var( 'paged' ) (or page on a static front page) and pass it into WP_Query, then render paginate_links() with max_num_pages.

the_posts_pagination() shows nothing on a custom loop

Symptom: pagination links never appear under a WP_Query loop. Cause: the_posts_pagination() reads the main query, not your custom one. Fix: use paginate_links() with the total set from your query object instead.

Changing posts_per_page in the template does nothing

Symptom: setting posts_per_page on an archive template has no effect on the listing. Cause: archive templates render the main query, which is built before the template loads. Fix: change it in a pre_get_posts callback for the relevant archive.

Frequently Asked Questions

Do I need the Loop on a single post or page?

Yes. Even with one post, the_post() sets up the global $post object that template tags read from. Without it, the_title() and the_content() return nothing. Run the while loop even for a single item.

What is the difference between the_post() and setup_postdata()?

the_post() advances the query to the next post and sets up its global data in one step, and it works with have_posts(). setup_postdata() only sets up the global data for a post object you already have, which is why you use it inside a foreach over get_posts() results.

When should I use WP_Query instead of get_posts()?

Use WP_Query when you need pagination, the full loop API, or you are building a secondary loop with template tags. Use get_posts() for a quick, finite list such as related posts or a dropdown. get_posts() is a thin wrapper around WP_Query under the hood.

Why does my second loop show the same posts as the first?

You are almost certainly calling the bare have_posts() and the_post() for both loops. Those always talk to the main query. Give each loop its own WP_Query object and call have_posts() and the_post() on that object.

How do I show different content when there are no posts?

Add an else branch to the if ( have_posts() ) check. The code there runs only when the query returns nothing, which is the right place for a "no results" message or a search form.

Can I change which posts an archive shows from the template?

You should not. The archive listing is the main query, built before the template runs. Use the pre_get_posts hook to change it. That keeps pagination working and is the supported approach.

Do these Loop patterns apply to block themes?

The PHP Loop powers classic themes and any custom PHP template you write. Block themes render listings with the Query Loop block in the Site Editor instead, so you rarely write a PHP Loop by hand there. The underlying ideas still apply: the Query Loop uses the main query on archive templates, and you can configure it to run a custom query much like a secondary WP_Query. If you build dynamic blocks with a PHP render callback, you write a normal WP_Query loop inside that callback, so these patterns remain useful.

Conclusion

The Loop looks like many different things across a theme, but it is really one pattern with small variations. Listing templates wrap it in an if/else and add pagination. Single templates run a bare while. Custom queries swap the bare functions for calls on a WP_Query object and always reset afterward.

Keep the four core ideas in mind and you can write any Loop from memory: have_posts() controls the flow, the_post() sets up the data, template tags read that data, and wp_reset_postdata() cleans up after a custom loop. Master those and every template in WordPress becomes predictable.

Next in this cluster, WP_Query Explained covers every argument you can pass to a custom query in depth, and How to Modify Main Queries with pre_get_posts shows the correct way to change archive listings without touching the template.