Mastering WP_User_Query: building a scalable member directory in WordPress

Mastering WP_User_Query: building a scalable member directory in WordPress

Last verified: September 22, 2026
8 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.

#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

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.

#Building a team page with role__in

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

$paged = max( 1, (int) get_query_var( 'paged', 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 ) ) {
	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.
  • Bios still need a meta read. If the grid is hot, cache the rendered HTML or precompute a directory row in a custom table.

#Pagination without loading every user

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, (int) ( $_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

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.
  • 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.
  • 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.

#Performance pitfalls

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

#Limit 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.

#Count without hydrating profiles

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

For role totals without filters, count_users() is cheaper because it uses the role counts WordPress already maintains.

#Cache stable result sets

If the directory changes rarely, store IDs (or rendered markup) in a transient keyed by filter hash. Bust the transient when profile meta updates via updated_user_meta for the keys you filter on. Do not cache HTML that includes capability-gated fields for anonymous visitors.

#Know when MySQL is the wrong index

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.

#Avoid N+1 avatar and meta reads

Inside the loop, get_avatar() and repeated get_user_meta() calls multiply. Prefetch known keys with a single query against wp_usermeta for the ID set on the page, or use a denormalised directory row written on profile save.

#Security: user enumeration on directories

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.
  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.

#Putting a directory template together

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.

#Multisite and shared user tables

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.

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.

#Testing the SQL you actually ship

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.

#Checklist before you ship

  1. Use fields so you do not hydrate full user objects for card grids.
  2. Paginate with number + paged and trust get_total() for page math.
  3. Prefer exact meta keys over LIKE on serialised skill arrays.
  4. Cache ID lists or markup when filters are stable; invalidate on profile writes.
  5. Render display_name only; hide logins and emails from public HTML.
  6. 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.

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.

Article FAQ

Frequently asked questions

Practical answers to apply the topic in real execution.

SEO-readyGEO-readyAEO-ready4 Q&A
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