WordPress

Your Child Theme Keeps Losing: A WordPress Template Hierarchy Guide

13 min read

A site we worked on had a beautifully customized archive layout sitting in its child theme. Card grid, filters, the works. It rendered perfectly on category pages and tag pages, and then you’d click an author name and get a completely different page — the parent theme’s plain stacked list, untouched by any of that work. Nobody had edited the author page. Nobody could find the file that was producing it.

The customized file was archive.php in the child theme. The parent theme happened to ship an author.php. WordPress looks for author.php first on an author archive, found the parent’s copy, loaded it, and stopped looking. The child theme’s archive.php was never consulted, because WordPress had already answered the question.

That’s the template hierarchy: the documented order in which WordPress decides which single PHP file (or block template) renders the page a visitor asked for. It runs on every request, it never asks your opinion, and once you can read it you stop guessing which file to edit. This guide walks through how the decision actually gets made in core, the child-theme rule that trips up most people, what block themes changed, and how to make WordPress just tell you which template it picked.


Two questions, answered in order

Every front-end request in WordPress resolves in two distinct stages, and mixing them up is where most template confusion starts.

First, WordPress parses the URL and runs a database query. By the time your theme gets involved, WordPress already knows what it found: a single post, a category archive, an author archive, a search with no results, nothing at all. That result is stored in the main query, and a family of conditional functions like is_single(), is_author(), and is_404() report on it.

Second — and only then — WordPress picks a file. It walks a list of conditionals in a fixed order, asks each one “is this you?”, and the first one that says yes gets to nominate a template. That’s the whole mechanism. The query decides what the page is; the hierarchy decides which file draws it.

The ladder, in core’s own order

The list lives in wp-includes/template-loader.php, and it’s short enough to read in full. Seventeen conditionals, checked top to bottom:

is_embed              -> get_embed_template()
is_404                -> get_404_template()
is_search             -> get_search_template()
is_front_page         -> get_front_page_template()
is_home               -> get_home_template()
is_privacy_policy     -> get_privacy_policy_template()
is_post_type_archive  -> get_post_type_archive_template()
is_tax                -> get_taxonomy_template()
is_attachment         -> get_attachment_template()
is_single             -> get_single_template()
is_page               -> get_page_template()
is_singular           -> get_singular_template()
is_category           -> get_category_template()
is_tag                -> get_tag_template()
is_author             -> get_author_template()
is_date               -> get_date_template()
is_archive            -> get_archive_template()

Read that list as a set of tiebreakers and a lot of WordPress behavior stops being mysterious. An author archive is both an author archive and a generic archive, so is_author and is_archive are both true — but is_author is checked first, so author.php wins over archive.php every time it exists. A single blog post satisfies is_single and is_singular; is_single comes first, so single.php beats singular.php. And because the loop breaks the moment a getter returns an actual file path, everything below the winning row is simply never evaluated.

There’s one more subtlety worth internalizing. A conditional matching isn’t enough — the getter has to actually find a file. If is_author() is true but your theme has no author.php and no more specific variant, get_author_template() returns an empty string, the loop keeps going, and is_archive gets its turn. Matching the conditional gets you an audition, not the part.

Where index.php comes in

If all seventeen conditionals come up empty, WordPress calls get_index_template() and renders index.php. That’s the floor, and it’s why a classic theme containing nothing but style.css and index.php is a functioning theme — every request that finds no better match lands there.

Core enforces this. WP_Theme raises a theme_no_index error for a non-block theme with no index.php at its root, which is what produces the “broken theme” notice on the Themes screen rather than a blank white page.

READ IT IN THE SOURCE

You don’t have to take anyone’s word for the order. WordPress is open source, and the entire decision lives in two files you already have on your server: wp-includes/template-loader.php for the conditional ladder, and wp-includes/template.php for the per-type candidate lists. Fifteen minutes with those two files beats any diagram, including ours.

Each page type builds its own candidate list

Once a conditional wins, its getter assembles an ordered array of filenames — most specific first, generic last — and hands it to locate_template() to find the first one that exists on disk. The lists are short and defined in core, so they’re worth knowing for the page types you actually customize. They aren’t immovable, though: each one passes through a dynamic {$type}_template_hierarchy filter on the way out, which is exactly how a plugin adds or reorders candidates.

Author archives

get_author_template() builds three candidates:

author-{user_nicename}.php
author-{ID}.php
author.php

The nicename is the URL-safe version of the login, so a user whose archive lives at /author/jane-doe/ gets author-jane-doe.php as the top candidate. Handy for a founder’s page that should look different from everyone else’s, and a good reminder that author archives publicly expose that nicename whether or not you meant them to.

Single posts and custom post types

get_single_template() is the busiest of the bunch. For a post of type product with the slug blue-widget, the list is:

{custom template assigned in the editor}
single-product-blue-widget.php
single-product.php
single.php

That first row is the one people forget. A template picked from the Template dropdown in the editor sidebar is stored per-post and inserted above everything else, so it outranks all the filename conventions. If a single post is stubbornly rendering the wrong layout while its neighbours behave, check that dropdown before you go rewriting theme files.

Pages

get_page_template() follows the same shape with a slug and an ID:

{custom template assigned in the editor}
page-{slug}.php
page-{ID}.php
page.php

The slug-based row is the reason a file named page-contact.php silently takes over the moment someone publishes a page at /contact/. It’s a genuinely useful convention and an occasional foot-gun, since renaming that page’s slug quietly detaches it from the template again.

QUICK REFERENCE

Blog post: single-post-{slug}.php then single-post.php then single.php. Page: page-{slug}.php then page-{ID}.php then page.php. Category: category-{slug}.php then category-{ID}.php then category.php then archive.php. Search results: search.php. Not found: 404.php. Everything unmatched: index.php.

The child theme rule almost everyone has backwards

Ask a room of WordPress developers how child themes work and you’ll get a clean, confident answer: the child theme overrides the parent. Drop a file in the child and it wins.

That’s true for any given filename, and it’s false as a general statement about which template renders. The distinction costs people whole afternoons.

Specificity is the outer loop, not the theme

Here’s what locate_template() actually does. It receives the ordered candidate list, and for each filename in turn, it checks the child theme’s directory, then the parent theme’s directory, and returns the first hit it finds:

for each candidate filename:
    does it exist in the child theme?   -> use it, stop
    does it exist in the parent theme?  -> use it, stop
move to next candidate filename

The filename loop is on the outside. The child-before-parent check is on the inside. So a more specific filename in the parent theme beats a less specific filename in the child theme, every single time.

Say your child theme has a lovingly rewritten single.php and the parent ships a single-product.php you’ve never opened. On a product page, the candidate list starts with single-product.php. WordPress checks the child for that exact filename (not there), checks the parent (there it is), and returns the parent’s file. Your single.php is next in line and never gets asked. The child theme didn’t lose because child themes are weak — it lost because it brought a generic file to a specific fight.

The version that bit us

The author-archive problem from the intro is the same rule playing out one level up, across two different getters. The child theme’s customization went into archive.php. But on an author archive, is_author is checked before is_archive, so get_author_template() runs first, finds the parent theme’s author.php, and the loop breaks. get_archive_template() is never called, so the child’s archive.php is never even a candidate.

The fix itself was mechanical: copy the parent’s author.php into the child theme, then port over the card-grid markup the child’s archive.php was already using. Finding the problem took considerably longer than fixing it, which is the usual ratio with this stuff.

WHY IT MATTERS

Template confusion doesn’t announce itself as a bug. It shows up as a page that “just looks wrong,” which gets patched with a stack of CSS overrides aimed at markup nobody located. Those overrides survive every theme update, pile up over years, and eventually nobody can safely delete any of them. Spending two minutes to identify which file is actually rendering is the difference between a one-line fix and a permanent layer of defensive stylesheet.

Block themes rearranged the furniture

If your site runs a block theme — the kind edited through the Site Editor, with HTML templates instead of PHP files — the hierarchy didn’t go away. It got a second resolver bolted on beside the first.

What makes a theme a block theme

Core’s test is refreshingly literal. WP_Theme::is_block_theme() looks for a readable index.html file in the theme’s templates directory (or the older block-templates directory). If that file’s there, it’s a block theme. That’s the whole check — no header declaration, no theme support flag, just the presence of a file.

Specificity still decides, across both worlds

Block templates are resolved by locate_block_template(), which runs after the classic lookup and gets handed both the PHP result and the same ordered candidate list. It bows out immediately unless the active theme supports block-templates, which block themes get automatically and a classic theme has to declare. Its logic is the interesting part: if the classic lookup already found a PHP template, core finds that template’s position in the candidate list and discards every block template less specific than it, then looks for a block template among what’s left.

The practical translation is that specificity outranks format. A block template only takes over from a PHP template that core already located if it’s at least as specific in the hierarchy. A hybrid theme carrying both single-product.php and a generic index.html will keep using the PHP file on product pages, and the two systems coexist without either quietly winning on a technicality.

Stop guessing: make WordPress tell you

All of the above is worth understanding, but you shouldn’t be tracing it by hand on a live site at 4pm. There are two reliable ways to just ask it directly.

Query Monitor, for day-to-day work

Query Monitor is a free debugging plugin with more than 200,000 active installations, and its admin-bar menu includes a Template panel that names the template file currently rendering the page, along with the template parts it loaded and the conditionals that matched. Install it on staging, load the page that’s misbehaving, read the answer. No guessing required.

One filter, when you can’t install from the plugin directory

If installing something from the plugin directory isn’t an option, template_include is the hook you want. It fires with the resolved path immediately before WordPress includes it, so logging from there tells you exactly what won:

add_filter( 'template_include', function ( $template ) {
    if ( current_user_can( 'manage_options' ) ) {
        error_log( 'Template: ' . $template );
    }
    return $template;
}, 999 );

Drop that in a must-use plugin — a single PHP file in wp-content/mu-plugins, which loads automatically with no activation step — rather than a theme file, so it survives a theme switch and can’t be lost in an update. Core’s own documentation in template-loader.php points at template_include as the supported way to change the loaded template, which makes it the right hook for overriding as well as observing. Return a different path from that filter and WordPress will render it, provided the path survives core’s own checks — it has to resolve to a readable file ending in .php or .html. That’s how plugins serve templates from their own directories.

A quick warning on that filter: it runs on every front-end request, so a permanent unconditional error_log() call will fill your debug log fast. Gate it on a capability check like the example above, or pull it out once you have your answer.

A cleaner alternative to fighting the hierarchy

Not every “the wrong template is loading” problem is really a template problem. Sometimes what’s actually missing is per-page control over the metadata or structured data on archive-style URLs that have no post to attach settings to. Our free, open-source amplifi.schema plugin handles that case with URL rules, which attach markup by path pattern instead of requiring a bespoke template file per archive. Worth knowing before you create author-jane-doe.php to solve what turns out to be a metadata problem.

Four mistakes worth avoiding

Editing the parent theme directly

It works right up until the parent updates and silently reverts everything. Put your overrides in a child theme, and remember that a child theme file only wins against the same filename in the parent.

Creating a template that never gets reached

Adding archive.php when the parent already has category.php, tag.php, and author.php means your new file only handles whatever’s left over. Work out which conditional fires for the URL you care about, then create the file that conditional’s getter actually asks for.

Assuming the homepage uses index.php

This one catches people constantly, because it used to be true. On a site with a static front page, is_front_page() matches first and the file is front-page.php, falling back to page.php. The separate posts page is where is_home() matches and home.php gets its shot. Both sit well above index.php in the ladder.

Debugging by CSS

Adding overrides until the page looks acceptable is faster today and expensive forever. Two minutes with Query Monitor tells you the filename, and editing the right file is almost always less work than maintaining a stylesheet that argues with markup you never found.

Frequently Asked Questions

The quickest way is the free Query Monitor plugin, which adds a Template panel to the admin bar showing the template file in use and the template parts it loaded. If installing from the plugin directory is not an option, hook the template_include filter in a must-use plugin, which is a single PHP file in wp-content/mu-plugins that loads with no activation step, and log the path it receives, since that filter fires with the resolved template immediately before WordPress includes it.

Almost always because the parent theme contains a more specific template for that page type. WordPress checks candidate filenames in order of specificity, and for each filename it looks in the child theme first and then the parent. A more specific file in the parent is therefore found before a more generic file in the child. The fix is to copy the specific parent file into your child theme and edit it there.

front-page.php renders whatever URL is set as the site front page, whether that is a static page or the blog listing. home.php renders the posts page specifically. In the template loader the is_front_page check runs before the is_home check, so on a site whose front page is the blog listing, front-page.php takes priority if it exists and home.php is used otherwise.

Yes. Block themes use the same ordered candidate list, resolved by a separate function that runs alongside the classic lookup. When a PHP template has already been located, core keeps only the block templates that are at least as specific as that PHP file before choosing. Specificity in the hierarchy decides the winner rather than the file format.

A classic theme needs style.css for its header information and index.php as the final fallback template. WordPress raises a theme_no_index error for a non-block theme missing index.php at its root. A block theme is identified instead by the presence of an index.html file in its templates directory, and core treats that file as the equivalent fallback.

Yes, and that is a normal, supported thing to do. The template_include filter receives the resolved template path just before WordPress includes it, so returning a different path from that filter renders that file instead. Core also exposes a dynamic filter for each page type that lets code modify the candidate filename list before the lookup runs, which is how plugins ship templates from their own directories.

If a page on your site has been “just looking wrong” for months and nobody can find the file, that’s a solvable afternoon, not a rebuild. We do this kind of WordPress work every week.

Built by amplifi.studio — see also The Update That Erased Your Edits: A WordPress Child Theme Guide.