WordPress Multisite get_sites(): Network-Wide Post Queries and switch_to_blog Patterns

WordPress Multisite networks consist of multiple sites sharing a single WordPress installation, database, and file system. While single-site WordPress development revolves around post queries and option lookups, multisite development adds a network layer: querying across all sites, running operations on each site in turn, reading and writing network-wide options, and understanding which operations respect site boundaries. The get_sites() function (introduced in WordPress 4.6 as the modern replacement for wp_get_sites()) queries the wp_blogs table and returns an array of WP_Site objects. Combined with switch_to_blog(), it lets you iterate every site on the network and perform operations with the correct database table prefix and site context active. This is the foundation for network-wide reporting, bulk content operations, cross-site search, and network admin tools.

Problem: You manage a WordPress Multisite network with 30 sites and need to generate a report of all published posts across every site — title, URL, site name, and post date — sortable for a network admin dashboard widget.

Solution: Query all sites with get_sites(), loop with switch_to_blog() / restore_current_blog(), and query posts on each site. Cache the result with a network-wide transient.

<?php
/**
 * Get recently published posts across all sites on the network.
 * Results are cached as a network transient for 1 hour.
 */
function get_network_recent_posts( int $per_site = 5 ): array {
    $cache_key = 'network_recent_posts_' . $per_site;
    $cached    = get_site_transient( $cache_key );
    if ( false !== $cached ) {
        return $cached;
    }

    $all_posts = [];

    // get_sites() queries wp_blogs — filters by active, not archived/deleted/spam
    $sites = get_sites( [
        'number'   => 200,
        'archived' => 0,
        'deleted'  => 0,
        'spam'     => 0,
        'public'   => 1,
    ] );

    foreach ( $sites as $site ) {
        switch_to_blog( $site->blog_id ); // sets global $wpdb table prefix, options cache

        $posts = get_posts( [
            'post_type'              => 'post',
            'post_status'            => 'publish',
            'posts_per_page'         => $per_site,
            'orderby'                => 'date',
            'order'                  => 'DESC',
            'no_found_rows'          => true,
            'update_post_meta_cache' => false,
            'update_post_term_cache' => false,
        ] );

        foreach ( $posts as $post ) {
            $all_posts[] = [
                'blog_id'   => $site->blog_id,
                'site_name' => get_bloginfo( 'name' ),
                'post_id'   => $post->ID,
                'title'     => $post->post_title,
                'url'       => get_permalink( $post->ID ),
                'date'      => $post->post_date,
            ];
        }

        restore_current_blog(); // always restore after switch_to_blog
    }

    // Sort all posts by date descending
    usort( $all_posts, fn( $a, $b ) => strcmp( $b['date'], $a['date'] ) );

    set_site_transient( $cache_key, $all_posts, HOUR_IN_SECONDS );
    return $all_posts;
}

// ── Network-wide option helpers ────────────────────────────────────────
// get/update/delete_site_option() = network-scoped (wp_sitemeta table)
$network_setting = get_site_option( 'my_network_plugin_setting', 'default' );
update_site_option( 'my_network_plugin_setting', 'new_value' );

// ── Query wp_blogs directly for advanced filters ───────────────────────
$large_sites = get_sites( [
    'number'       => 50,
    'site__not_in' => [ 1 ], // exclude main site
    'orderby'      => 'registered',
    'order'        => 'DESC',
] );

NOTE: Always call restore_current_blog() after every switch_to_blog() — even if an exception or early return occurs. Use a try/finally block in production code to guarantee the restore runs. Failing to restore leaves the global WordPress context pointing at the wrong site, corrupting all subsequent database queries and option reads for the rest of the request. get_site_transient() stores data in the wp_sitemeta table (network-scoped), not the site-scoped wp_options — use it instead of get_transient() for data that belongs to the network, not a single site.