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()plusget_total()for paginated directories- inspection of
$query->requestwhile 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__inmatches any listed role. Prefer it over multiple queries withrole.fieldsreturnsstdClassrows instead of fullWP_Userobjects, 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 witharray_slicein PHP - Running a second full query only to count rows when
get_total()already did the work - Putting untrusted
$_GETvalues intoorderbywithout 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) overLIKEon 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
relationcarefully. Nested groups are valid, but each clause is another join onwp_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.
- Never print
user_loginin frontend markup. Usedisplay_nameor a dedicated public nickname. - Never print
user_emailunless the viewer is authenticated and allowed to see it. - 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;
}
}
);- Escape everything that leaves PHP:
esc_html(),esc_url(),esc_attr()on attributes. - 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:
- Input sanitisation - allow-listed sort keys, integer page, normalised city slug.
- Query -
role__in/meta_query/fields/number/paged. - Presentation - cards from IDs and safe display fields only.
- 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_idwhen 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:
- Enable Query Monitor on staging.
- Open the directory URL with the heaviest filter combination marketing uses.
- Confirm one primary users query, not one query per card for meta.
- Save
$query->requestin 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
- Use
fieldsso you do not hydrate full user objects for card grids. - Paginate with
number+pagedand trustget_total()for page math. - Prefer exact meta keys over
LIKEon serialised skill arrays. - Cache ID lists or markup when filters are stable; invalidate on profile writes.
- Render
display_nameonly; hide logins and emails from public HTML. - 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.







