Mastering WP_User_Query: building a scalable member directory in WordPress

Mastering WP_User_Query: building a scalable member directory in WordPress

Last verified: October 12, 2026
18 min read
Guide
Full-stack developer
Core Web Vitals

WP_Query gets most of the tutorials. WP_User_Query is what membership sites, staff directories, and community platforms actually run on.

If you only need a static “Our team” grid, a lightweight query in a template beats another membership plugin that loads its own templates, REST routes, and CSS on every request. This guide stays on that problem: how to filter users with role__in, meta_query, and pagination without burning the database or leaking login names.

Official reference: WP_User_Query on developer.wordpress.org. Every default and behaviour described below was checked against the class source in wordpress-develop on 12 October 2026.

#get_users() vs WP_User_Query

get_users() is a thin wrapper around WP_User_Query. It accepts the same argument array and returns an array of WP_User objects (or field subsets when you pass fields).

Use get_users() when you want a short list and nothing else - for example five editors for a byline widget.

Use WP_User_Query when you need:

  • get_results() plus get_total() for paginated directories
  • inspection of $query->request while debugging SQL
  • the same args shape as get_users(), but with explicit query object lifecycle

There is one behavioural difference worth knowing. get_users() forces count_total to false before it builds the query, so it never runs the extra count and has no total to give you. That is the right default for a widget and the wrong one for a paginated directory.

For both APIs the argument array is the contract. Everything below works with either form; the examples use WP_User_Query because directories need totals.

#What WP_User_Query returns with default arguments

The defaults decide most of the cost of a careless query, so it pays to know them:

ArgumentDefaultWhat it means in practice
numberno limitEvery matching user is returned. The docblock says -1 (all) “should be used with caution on larger sites”.
paged1Only used when number is greater than zero.
orderbyloginSorted by user_login, which is rarely what a public list wants.
fieldsallFull WP_User objects, with all user meta primed through cache_users().
count_totaltrueAdds SQL_CALC_FOUND_ROWS and runs a second SELECT FOUND_ROWS() query.
blog_idcurrent siteRole and membership filters apply to the current site only.
cache_resultstrueResults are stored in the object cache (added in WordPress 6.3).

Two consequences follow. A query without number on a site with a large user base loads everyone. And a query that never paginates should pass 'count_total' => false, because the docblock says turning it off “can improve performance” when pagination is not needed.

The class also refuses to run early: since WordPress 6.1.1, a user query fired before the plugins_loaded hook triggers a _doing_it_wrong() notice.

#WP_User_Query role__in example for a team page

Editors and authors, sorted by display name, twelve per page:

$paged = max( 1, absint( $_GET['team_page'] ?? 1 ) );

$args = [
	'role__in' => [ 'editor', 'author' ],
	'orderby'  => 'display_name',
	'order'    => 'ASC',
	'number'   => 12,
	'paged'    => $paged,
	'fields'   => [ 'ID', 'display_name' ],
];

$user_query = new WP_User_Query( $args );
$results    = $user_query->get_results();

if ( ! empty( $results ) ) {
	// One query for the meta of all twelve users instead of twelve queries.
	update_meta_cache( 'user', wp_list_pluck( $results, 'ID' ) );

	echo '<div class="team-grid">';
	foreach ( $results as $user ) {
		$avatar = get_avatar( $user->ID, 128 );
		$name   = esc_html( $user->display_name );
		$bio    = esc_html( get_user_meta( $user->ID, 'description', true ) );

		echo "<article class='team-member'>
			<figure>{$avatar}</figure>
			<h3>{$name}</h3>
			<p>{$bio}</p>
		</article>";
	}
	echo '</div>';
}

Notes that matter in production:

  • role__in matches any listed role. Prefer it over multiple queries with role.
  • fields returns stdClass rows instead of full WP_User objects, which cuts memory when you only need IDs and names.
  • With a fields array WordPress does not prime user meta, so the update_meta_cache() call above is what keeps the bio lookups at one query. With fields => 'all' the class does that priming for you through cache_users().
  • The page number comes from a dedicated team_page parameter. On a static page the main query uses page, not paged, so reading get_query_var( 'paged' ) there silently returns the default.

#How role, role__in and capability filters work

The three role arguments look similar and behave differently:

  • role accepts a string or an array, and the array is an AND list: a user must have each listed role. 'role' => [ 'editor', 'author' ] returns almost nobody on a standard site.
  • role__in is the OR list: at least one of the roles.
  • role__not_in excludes users who hold any of the listed roles.

Under the hood none of these touch a roles table, because WordPress has none. Roles live in a serialised array in the {prefix}capabilities user meta row, and every role clause becomes a LIKE '%"editor"%' comparison on that row. A role filter is already a wp_usermeta join before you add a single meta_query.

Since WordPress 5.9 there are also capability, capability__in and capability__not_in. They map capabilities to the roles that grant them, plus capabilities added directly to a user. The docblock warns that they do not work for capabilities that are not stored in the database or that are filtered through map_meta_cap. Treat them as a convenience for primitive capabilities like edit_posts, not as a permission check.

#How to paginate WP_User_Query with get_total()

Pagination args are number (page size) and paged (1-based). After the query, get_total() is the match count across all pages - not just the current page.

$per_page = 24;
$paged    = max( 1, absint( $_GET['udir_page'] ?? 1 ) );

$query = new WP_User_Query(
	[
		'role__in' => [ 'subscriber', 'contributor' ],
		'number'   => $per_page,
		'paged'    => $paged,
		'fields'   => 'ID',
		'orderby'  => 'registered',
		'order'    => 'DESC',
	]
);

$total_users = (int) $query->get_total();
$total_pages = (int) ceil( $total_users / $per_page );
$user_ids    = $query->get_results();

// Build links with add_query_arg( 'udir_page', $n ) and escape URLs.

Anti-patterns to avoid:

  • Fetching 'number' => -1 (or omitting it) and slicing with array_slice in PHP
  • Running a second full query only to count rows when get_total() already did the work
  • Putting untrusted $_GET values into orderby without a hard allow-list

Two details from the source catch people out. If you pass offset, it wins: the class builds LIMIT offset, number and ignores paged. And with fields => 'ID' the result is a flat array of ID strings from $wpdb->get_col(), not objects, so cast before strict comparisons.

For public directories, use a dedicated query var (as above) so you do not collide with the main blog paged loop.

#Advanced filtering with meta_query

meta_query is where directories get useful - and where MySQL cost shows up. Example: subscribers in Warsaw, public profiles only, with PHP listed in a skills meta field.

$args = [
	'role'       => 'subscriber',
	'number'     => 20,
	'paged'      => 1,
	'fields'     => [ 'ID', 'display_name' ],
	'meta_query' => [
		'relation' => 'AND',
		[
			'key'     => 'city',
			'value'   => 'Warsaw',
			'compare' => '=',
		],
		[
			'key'     => 'is_public_profile',
			'value'   => '1',
			'compare' => '=',
		],
		[
			'key'     => 'skills',
			'value'   => 'PHP',
			'compare' => 'LIKE', // works on serialised arrays; expensive at scale
		],
	],
];

$directory = new WP_User_Query( $args );

Practical rules for meta_query on users:

  • Prefer exact = compares on dedicated keys (city, is_public_profile) over LIKE on serialised blobs.
  • LIKE 'PHP' is a substring match, so it also matches a skill called PHPUnit. That is a correctness bug, not only a speed problem.
  • Store multi-value skills as separate boolean meta keys (skill_php = 1) if you filter them often. That turns OR/AND into index-friendly equality checks. Another option is one skills row per value, written with add_user_meta( $user_id, 'skills', 'php' ), which allows duplicate keys, and then filtered with 'compare' => '=' or 'IN'.
  • Combine relation carefully. Nested groups are valid, but each clause is another join on wp_usermeta.
  • Always pair public filters with a visibility flag so private profiles never appear in unauthenticated listings.

The reason exact keys matter is visible in the core schema. The wp_usermeta table created by wp-admin/includes/schema.php has a primary key, an index on user_id and a prefix index on meta_key. There is no index on meta_value, which is a longtext column. MySQL can narrow the join to the rows of one key, but it has to read every one of those rows to compare values.

#How WP_User_Query search and search_columns work

The search argument is easy to misuse because it behaves differently from a search box.

  • Without wildcards it is an exact match. 'search' => 'anna' finds a user whose login is exactly anna. Add * at the start, the end or both ('*anna*') to get a LIKE with %.
  • search_columns accepts ID, user_login, user_email, user_url, user_nicename and display_name. ID is always compared with =.
  • When search_columns is empty, WordPress guesses: a term with @ searches user_email, a numeric term searches user_login and ID, a URL searches user_url, and anything else searches login, URL, email, nicename and display name together.

That last default is the dangerous one for a public directory. A visitor typing part of an email address into your search box can confirm that the address belongs to a member. For public search, always set the columns yourself:

$term = sanitize_text_field( wp_unslash( $_GET['q'] ?? '' ) );

$args = [
	'role__in'       => [ 'subscriber' ],
	'search'         => '*' . $term . '*',
	'search_columns' => [ 'display_name' ],
	'number'         => 20,
	'fields'         => [ 'ID', 'display_name' ],
];

The user_search_columns filter lets a plugin change the column list for every user search on the site, so check for it when results look wrong.

#How to speed up WP_User_Query on large directories

User queries feel cheap until the directory is public and filtered.

#How to limit WP_User_Query returned fields

By default WordPress hydrates more data than a card grid needs. Pass fields early:

$args = [
	'role'   => 'subscriber',
	'number' => 100,
	'fields' => [ 'ID', 'display_name', 'user_email' ],
];

Drop user_email from public templates. Keep it only for staff tools behind capability checks.

Keep the list short for a second reason: when a fields array names more than three fields, WP_User_Query switches its own result cache off for that query.

#How to count users without loading profiles

$query = new WP_User_Query(
	[
		'role'   => 'subscriber',
		'number' => 1,
		'fields' => 'ID',
	]
);
$count = $query->get_total();

number => 1 keeps the result set to one ID while get_total() still reports the full count.

For role totals without filters there is count_users(). It is not free: there is no stored role counter behind it. With the default time strategy it runs one aggregate query over every {prefix}capabilities row with a LIKE per role, and its docblock describes it as CPU-intensive and good for around 10^7 users. If you only need the total number of accounts, get_user_count() reads a stored user_count option. On sites that wp_is_large_user_count() treats as large (more than 10,000 users by default) that value is refreshed by a twice-daily cron event, so it can lag.

#How to cache WP_User_Query results

Since WordPress 6.3 the class caches for you. With cache_results left at true, it stores the result IDs and the total in the user-queries object cache group, keyed by an MD5 of the generated SQL. The entry is salted with the users last-changed timestamp, and core bumps that timestamp on added_user_meta, updated_user_meta, deleted_user_meta, add_user_role, set_user_role and remove_user_role. Any profile edit invalidates every cached user query.

That cache only survives between requests when the site runs a persistent object cache (Redis or Memcached). Without one it lasts for a single page load.

If you add your own layer on top, for example a transient with the rendered HTML, key it by a hash of the filters and bust it from the same hooks. Do not cache HTML that includes capability-gated fields for anonymous visitors. And keep frequent meta writes out of the request path: a plugin that updates a “last seen” meta key on every page view bumps the users timestamp on every hit and leaves the built-in cache with nothing to serve.

#When to move a member directory to Elasticsearch

wp_usermeta joins do not scale like a search engine. When filters multiply (city + specialty + language + availability) and the audience is large, move the searchable projection to Elasticsearch (ElasticPress or a custom index) or a dedicated directory table with real indexes. Keep WP_User_Query for the final hydrate of a small ID list.

The class has a hook for exactly this. Returning a non-null array from the users_pre_query filter skips the SQL entirely; the docblock asks filters that do so to set the total_users property so pagination keeps working.

#How to avoid N+1 avatar and user meta reads

Inside the loop, get_avatar() and repeated get_user_meta() calls multiply. Prime the meta for the whole page with update_meta_cache( 'user', $ids ), which loads every meta row for the ID set in one query, as in the team page example. For very wide profiles, a denormalised directory row written on profile save is cheaper still.

#Who can list users through the WordPress REST API

Your template may be locked down while /wp-json/wp/v2/users still answers. What it returns depends on the visitor, and the rules are in WP_REST_Users_Controller, summarised in the users endpoint reference:

  • A visitor without the list_users capability (by default only administrators have it) only sees users who have published posts in a post type exposed to the REST API. Members who never published stay out of the anonymous list.
  • In the default view context a user object carries id, name, url, description, link, slug and avatar_urls. username, email, roles and registered_date exist only in the edit context, which requires list_users.
  • Filtering by roles or capabilities, ordering by email or registered_date, and context=edit all return an authorisation error without list_users.
  • For those visitors a search is pinned to ID, user_login, user_nicename and display_name. The search_columns parameter, added in WordPress 6.8, cannot widen that list.
  • per_page defaults to 10 and is capped at 100, and the response carries X-WP-Total and X-WP-TotalPages headers.

The slug field is the weak spot. wp_insert_user() builds user_nicename from the login when no nicename is given, by running the first 50 characters of user_login through sanitize_title(). For most accounts the public slug therefore is the login, or close to it. The same nicename appears in author archive URLs.

Two filters give you control. rest_user_query receives the WP_User_Query arguments for every list request, so you can add a visibility meta_query there. If the site has no public use for the endpoint, unregister it for anonymous visitors only, because the block editor reads users through the same route for logged-in editors:

add_filter(
	'rest_endpoints',
	static function ( $endpoints ) {
		if ( ! is_user_logged_in() ) {
			unset( $endpoints['/wp/v2/users'], $endpoints['/wp/v2/users/(?P<id>[\d]+)'] );
		}
		return $endpoints;
	}
);

#How to prevent user enumeration in a member directory

A member directory is a public list of accounts. Treat it like one.

  1. Never print user_login in frontend markup. Use display_name or a dedicated public nickname. Remember that user_nicename is derived from the login by default, so it is not a safe substitute.
  2. Never print user_email unless the viewer is authenticated and allowed to see it.
  3. Author archives (/?author=1) still leak logins on many sites. If the product does not need public author archives, redirect them:
add_action(
	'template_redirect',
	static function () {
		if ( is_author() ) {
			wp_safe_redirect( home_url( '/' ), 301 );
			exit;
		}
	}
);
  1. Escape everything that leaves PHP: esc_html(), esc_url(), esc_attr() on attributes.
  2. Capability-gate admin-only directories (list_users) instead of relying on obscurity in the URL.
  3. Restrict public search_columns to display_name, as shown above.

#Is a member directory personal data under GDPR

For members in the EU or EEA, yes. Names, photos, cities and skills of identifiable people are personal data under Article 4(1) of the General Data Protection Regulation. Article 25(2) adds a rule that maps straight onto your query arguments: by default, personal data must not be made accessible to an indefinite number of people without the individual’s intervention.

In code that means:

  • New accounts start with is_public_profile = 0 until the member opts in.
  • Separate flags for “show phone” or “show email”: a public profile is not consent to publish contact details.
  • Every public query includes the visibility clause in its meta_query, and so does any rest_user_query filter.
  • Staging databases with real members are anonymised before anyone outside the team gets a copy.

WP_User_Query only filters. It does not replace consent, a stated purpose or a deletion policy, but without the visibility flag no privacy notice will stop the profile from appearing.

#Membership plugin or your own directory with WP_User_Query

Membership plugins earn their place for signup, payment tiers and messaging. The public listing is a smaller problem, and it is the one that runs on every anonymous request. Keep the plugin for the account flow and build the listing yourself when it has to be fast or tightly filtered.

If you are already tied to a plugin’s grid, measure it before accepting it: open the listing with Query Monitor and check that the user query has a LIMIT, requests only the fields the cards show, and does not trigger one meta query per card.

#WordPress member directory template structure

A durable public directory usually has four layers:

  1. Input sanitisation - allow-listed sort keys, integer page, normalised city slug.
  2. Query - role__in / meta_query / fields / number / paged.
  3. Presentation - cards from IDs and safe display fields only.
  4. Cache invalidation - tied to the meta keys that affect visibility.

Keep plugin UI for account signup and messaging if you need those features. Keep listing and filtering in your theme or a small mu-plugin you control. That split is what keeps the directory fast when marketing adds another filter next quarter.

#How WP_User_Query handles roles on multisite

On a WordPress multisite network the users table is shared. WP_User_Query still works, but role filters are per-site. A user who is an Editor on site A may have no role on site B. For a network-wide people directory:

  • Prefer blog_id when you must scope roles to one site.
  • Do not assume role__in => array( 'author' ) means the same people on every blog.
  • For cross-site staff lists, store a network meta flag (for example wpp_show_in_directory) and query that instead of roles alone.

The source explains why. On multisite, even a query with no role arguments adds an EXISTS clause on the current site’s {prefix}capabilities key, so only members of that site come back. Passing 'blog_id' => 0 skips the role and membership clauses and queries the whole network.

Staging copies of multisite often truncate users. Measure query cost on a dump that matches production cardinality, or your meta_query plan will look fine until launch day.

#How to test WP_User_Query SQL with Query Monitor

Before merging:

  1. Enable Query Monitor on staging.
  2. Open the directory URL with the heaviest filter combination marketing uses.
  3. Confirm one primary users query, not one query per card for meta.
  4. Save $query->request in the PR notes so the next person can spot regressions.

If the directory must support free-text search across display names, use a dedicated search index or a tightly scoped search argument on WP_User_Query. Do not LIKE across user_email on a public endpoint. Log slow directory requests with the filter payload so you can reproduce them later.

Need a custom WordPress directory or membership query reviewed for production? Talk to a WordPress developer.

#WP_User_Query member directory checklist

  1. Use fields so you do not hydrate full user objects for card grids, and stay at three fields or fewer to keep the built-in cache.
  2. Paginate with number + paged and trust get_total() for page math; pass count_total => false when there is no pagination.
  3. Prefer exact meta keys over LIKE on serialised skill arrays.
  4. Prime meta with update_meta_cache() before the loop.
  5. Cache ID lists or markup when filters are stable; invalidate on profile writes.
  6. Render display_name only; hide logins, nicenames and emails from public HTML and public search.
  7. Check what /wp/v2/users returns to an anonymous visitor.
  8. Measure the SQL ($query->request) on staging with realistic user and meta volume before launch.

Users are first-class query targets in WordPress. Query them with the same discipline you already apply to WP_Query.

For the delivery side of this topic we keep the work under WordPress security audit.

Next step

Turn the article into an actual implementation

This block strengthens internal linking and gives readers the most relevant next move instead of leaving them at a dead end.

Related cluster

Explore other WordPress services and knowledge base

Strengthen your business with professional technical support in key areas of the WordPress ecosystem.

Frequently asked questions

Practical answers to apply the topic in real execution.

When should I use WP_User_Query instead of get_users?#
Use get_users() for simple lists where you only need an array of WP_User objects. Reach for WP_User_Query when you need fine-grained orderby, meta_query, fields control, pagination totals via get_total(), or direct access to the SQL the query produced.
How do I paginate a member directory safely?#
Pass number (page size) and paged (1-based page index) in the args array, then use get_total() for the full match count. Never load every user into memory and slice in PHP - that pattern collapses under real traffic.
Why is meta_query slow on large member directories?#
Each meta_query clause joins wp_usermeta. LIKE compares and serialised skill arrays force table scans. Prefer exact keys, keep public-directory flags indexed in a custom table, or offload search to Elasticsearch when the directory grows past what MySQL joins handle comfortably.
What should I never print in a public member listing?#
Never output user_login or user_email on a public directory. Render display_name (or a dedicated public nickname meta field) and gate email behind capability checks or authenticated intranet views only.

Need an FAQ tailored to your industry and market? We can build one aligned with your business goals.

Let’s discuss

Related Articles