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.

Enable JavaScript to customise; default output below.

Archived

Set it either way rather than leaving it null, which returns archived and live sites together.

Spam
Deleted

A deleted site is flagged, not removed: its tables are still there until somebody deletes them for real.

Zero to leave it out. Only relevant on a multi-network install, where it defaults to the current network.

With both slashes: /shop/ rather than shop. That is how the column stores it.

Matches the domain and path columns only. It does not search anything inside the sites.

Site 1 is usually the main site, which a network-wide loop often wants to skip.

Direction

The default is 100. On a large network, unbounded queries are what take the admin down.

Live preview site-query.php
<?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.

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

  1. Set the flags explicitly, even to their obvious values.
  2. Choose IDs only or count only when that is all you need.
  3. 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?
Check the flags first: it may be archived or marked spam. Then check 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?
No. $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?
Slow enough to matter in a loop: it changes the database prefix and clears several caches. A few switches are fine; a few hundred in a page request are not, and that work belongs in a cron job or a background task.
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.

From the people who built this tool

WP Adminify

The WordPress admin, rebuilt: a dashboard worth looking at, menu and column control, a real file manager and the login page your client sees.

See WP Adminify Free version on WordPress.org

Weekly drops

New tools, when there are new tools

One email when something worth using ships. No schedule to fill, so no filler.

Your address goes nowhere else, and one click unsubscribes.