WP_Site_Query Generator
get_sites() arguments with the spam, deleted and archived flags set explicitly, because their default is null and null means either.
<?php
/**
* Sites on the network matching the arguments below.
*
* The defaults are the trap here: public, archived, spam and deleted are all null unless you set
* them, and null means "either". A query written without them returns spam and deleted sites
* alongside the real ones, which is rarely what a site list is for.
*/
// get_sites() returns an empty array on a single-site install, so anything looping over it has to
// cope with that rather than assuming a network.
if ( ! is_multisite() ) {
return;
}
$args = array(
// 1 is public, 0 is "discourage search engines", null is both.
'public' => 1,
'archived' => 0,
'spam' => 0,
'deleted' => 0,
'site__not_in' => array_map( 'absint', explode( ',', '1' ) ),
'orderby' => 'domain',
'order' => 'ASC',
'number' => 100,
);
$sites = get_sites( $args );
foreach ( $sites as $site ) {
$id = (int) $site->blog_id;
// $site is already a WP_Site object, so reading its columns needs no further query and no
// switch. Anything beyond these columns, such as an option, does need one.
printf( "%d: %s%s\n", $id, esc_html( $site->domain ), esc_html( $site->path ) );
}
Output is valid and updates as you type.
Fix the highlighted fields to update the output.
The trap in get_sites() is the defaults. public, archived, spam and deleted are all null unless
you set them, and null means “either”. So the obvious query returns spam and deleted sites alongside the
real ones, and the site list you built quietly includes the three sites somebody flagged last year.
The other thing worth knowing before writing a network-wide loop: switch_to_blog() is the expensive part.
It swaps the database prefix and flushes several caches, so a loop over 200 sites is 200 switches. Reading
the columns on the WP_Site object costs nothing; reading an option from each site costs a switch.
How to use
- Set the flags explicitly, even to their obvious values.
- Choose IDs only or count only when that is all you need.
- Tick the switch example if the loop has to read data from inside each site.
Example
if ( ! is_multisite() ) {
return;
}
$args = array(
// 1 is public, 0 is "discourage search engines", null is both.
'public' => 1,
'archived' => 0,
'spam' => 0,
'deleted' => 0,
'site__not_in' => array_map( 'absint', explode( ',', '1' ) ),
'orderby' => 'domain',
'order' => 'ASC',
'number' => 100,
);
$sites = get_sites( $args );
foreach ( $sites as $site ) {
$id = (int) $site->blog_id;
printf( "%d: %s%s\n", $id, esc_html( $site->domain ), esc_html( $site->path ) );
}
With the switch example turned on, the loop pairs every switch_to_blog() with a restore_current_blog(),
because an unpaired switch leaves the rest of the request pointed at the wrong site.
Pitfalls
Set the flags. 'spam' => 0, 'deleted' => 0, 'archived' => 0 should be in almost every site query you
write. The defaults are null for backward compatibility and they are almost never what you want.
A deleted site is not deleted. It is flagged, and its tables are still there. That is why it comes back in an unfiltered query, and why “deleting” a site in the network admin is reversible until somebody removes the tables.
number defaults to 100. Which is a sensible default and a surprise if you have 400 sites and assumed
you had them all. Set it explicitly, and paginate rather than raising it to 10,000.
Paths carry both slashes. The column holds /shop/, not shop. A query with 'path' => 'shop' matches
nothing and looks like a bug in WordPress.
search searches the domain and path columns. Not the site names, and certainly not the content. There
is no way to search inside sites from here: that needs a switch per site, or a separate index.
Every switch_to_blog() needs its restore_current_blog(). Including on the early-return path. An
unbalanced switch is one of the hardest multisite bugs to find, because the symptom appears somewhere else
entirely, often as the wrong site’s options in a footer.
fields => 'ids' is usually right for a large network. Site objects are primed into the cache when you
ask for them, which is wasted work if the loop only needs the ID to switch to.
get_sites() returns an empty array on single site. So any plugin that might run outside a network
needs the is_multisite() guard, which is why it is in the generated code.
Compatibility
Everything runs in the browser: nothing is uploaded and nothing is stored.
WP_Site_Query and get_sites() arrived in WordPress 4.6, replacing wp_get_sites(), which is deprecated
and had a hard limit of 100 sites. The argument names here are the current ones and have not changed since.
count => true returns an integer and skips building objects, which is the right way to count a network.
no_found_rows drops the SELECT FOUND_ROWS() query, which matters only when you are not paginating.
Every string is escaped for a PHP single-quoted string, and the hostile fixture contains an apostrophe,
*/, ?> and ${x}; the generated file passes php -l with the same token structure as the default one.
The ID lists go through absint at runtime as well, so a stray value cannot reach the query.
The switch_to_blog() example uses suppress_filters => false on the inner get_posts(), because the
default there suppresses filters and quietly changes which posts you get.
Frequently asked questions
How do I list every site on a network?
get_sites() with the flags set and number high enough, or paginated with number and offset. Do not
rely on the default 100.Why is a site missing from my results?
number, then whether it is on a
different network on a multi-network install.Do I need switch_to_blog to read a site’s name?
$site->domain and $site->path come from the query, and get_site( $id )->blogname is cached. You
need a switch to read options, posts or anything else that lives in the site’s own tables.Is switch_to_blog slow?
What replaced wp_get_sites()?
get_sites(), since 4.6. wp_get_sites() is deprecated, returns arrays rather than objects, and caps out
at 100 sites.