WordPress Hierarchical Taxonomy Queries: parent vs child_of, pad_counts, and Term Depth

WordPress’s term query system supports hierarchical taxonomies (categories, and any custom taxonomy registered with 'hierarchical' => true) with special query arguments that have no equivalent for flat taxonomies. These include parent (query only terms that are direct children of a given term ID), child_of (query all descendants, at any depth, of a term), depth (used by wp_list_categories() to control how many levels to display), and pad_counts (whether the displayed count should include posts in child terms). Understanding these arguments — and the important difference between parent and child_of — is essential for building breadcrumb navigation, hierarchical category menus, and post archives filtered by taxonomy ancestry. A common mistake is using child_of in WP_Term_Query expecting direct children — but child_of returns ALL descendants recursively, not just immediate children.

Problem: A news site has a "Topics" taxonomy with three levels: Sport → Football → Premier League. A sidebar widget needs: (a) all direct children of "Sport", (b) all descendants of "Sport" at any depth, (c) the total post count for "Sport" including posts in all child terms, and (d) the term depth/level for rendering indented menus.

Solution: Use WP_Term_Query with parent for direct children and child_of for all descendants. Use pad_counts for aggregated counts, and compute depth manually using get_ancestors().

<?php
$sport_term = get_term_by( 'slug', 'sport', 'topics' );
$sport_id   = $sport_term ? $sport_term->term_id : 0;

// ── (a) Direct children only ──────────────────────────────────────────
$direct_children = new WP_Term_Query( [
    'taxonomy'   => 'topics',
    'parent'     => $sport_id,  // DIRECT children only (depth 1)
    'hide_empty' => false,
    'orderby'    => 'name',
    'order'      => 'ASC',
] );
foreach ( $direct_children->get_terms() as $term ) {
    // Only Football, Basketball, Tennis (immediate children of Sport)
    echo esc_html( $term->name ) . "
";
}

// ── (b) All descendants at any depth ─────────────────────────────────
$all_descendants = new WP_Term_Query( [
    'taxonomy'   => 'topics',
    'child_of'   => $sport_id, // ALL descendants recursively
    'hide_empty' => false,
    'orderby'    => 'name',
] );
// Returns: Football, Premier League, Championship, Basketball, Tennis...

// ── (c) Pad counts: include child-term posts in parent's count ────────
$categories_padded = new WP_Term_Query( [
    'taxonomy'   => 'topics',
    'parent'     => 0,      // top-level terms
    'pad_counts' => true,   // count includes posts in all child terms
    'hide_empty' => true,
] );
foreach ( $categories_padded->get_terms() as $term ) {
    printf( "%s (%d)
", esc_html( $term->name ), $term->count );
    // Sport (247) — counts posts in Football, PL, Basketball, Tennis...
}

// ── (d) Compute term depth for indented menu rendering ────────────────
function get_term_depth( int $term_id, string $taxonomy ): int {
    // get_ancestors() returns array of ancestor IDs from nearest to root
    return count( get_ancestors( $term_id, $taxonomy, 'taxonomy' ) );
}

// Build an indented term list
$all_terms = get_terms( [
    'taxonomy'   => 'topics',
    'hide_empty' => false,
    'orderby'    => 'parent',  // parent-first ordering for correct hierarchy
] );
foreach ( $all_terms as $term ) {
    $depth  = get_term_depth( $term->term_id, 'topics' );
    $indent = str_repeat( '   ', $depth );
    printf( '%s%s (%d)<br>', $indent, esc_html( $term->name ), $term->count );
}

// ── Get full term hierarchy as nested array ───────────────────────────
function build_term_tree( string $taxonomy, int $parent = 0 ): array {
    $children = get_terms( [
        'taxonomy'   => $taxonomy,
        'parent'     => $parent,
        'hide_empty' => false,
    ] );
    $tree = [];
    foreach ( $children as $term ) {
        $tree[] = [
            'term'     => $term,
            'children' => build_term_tree( $taxonomy, $term->term_id ),
        ];
    }
    return $tree;
}

NOTE: pad_counts makes an extra database query to aggregate child counts — avoid it in performance-critical contexts; instead cache the result in a transient. The parent argument queries the term_taxonomy.parent column directly (fast, uses index). The child_of argument first fetches the direct children and then recursively fetches their children in PHP — on deep hierarchies with hundreds of terms this can be slow. For deep hierarchies, consider using a single query with get_ancestors() stored in term meta, or restructuring the taxonomy as a flat taxonomy with a dedicated meta field for the "parent path" (a materialized path pattern). The build_term_tree() recursive function above makes one DB query per level — for a three-level hierarchy this is three queries; cache the result if it's used on every page load.