WordPress WP_User_Query: Query Users by Role, Meta, Date, and Search with Pagination

WP_User_Query is the correct WordPress API for querying users — the user equivalent of WP_Query for posts. Like WP_Query, it translates a PHP argument array into an optimized SQL query against wp_users and wp_usermeta, handles caching, and supports pagination. The older get_users() function is a wrapper around WP_User_Query and returns an array of results directly — it is convenient for simple queries. For complex queries (multiple meta conditions, search across multiple fields, capability-based filtering, or when you need the total count for pagination), working with WP_User_Query directly gives you full control. Common use cases include building admin screens that list users filtered by role or meta, finding users who registered in a date range, searching users by a custom profile field, or exporting user data for a CRM.

Problem: A client admin page needs to list all users with the role subscriber who have a specific custom meta field set, have a Gmail email address, and registered in the last 30 days — with pagination showing 20 users per page.

Solution: Use WP_User_Query with role, meta_query, search, and date_query arguments. Retrieve get_total() for pagination, then loop get_results() for the current page.

<?php
// ── Basic user query ──────────────────────────────────────────────────
$query = new WP_User_Query( [
    'role'    => 'subscriber',
    'number'  => 20,
    'offset'  => 0,
    'orderby' => 'registered',
    'order'   => 'DESC',
] );

$users = $query->get_results(); // array of WP_User objects
$total = $query->get_total();   // total matching users (ignores number/offset)

// ── Full complex query ─────────────────────────────────────────────────
$per_page   = 20;
$page       = max( 1, absint( $_GET['paged'] ?? 1 ) );
$offset     = ( $page - 1 ) * $per_page;

$user_query = new WP_User_Query( [
    'role'    => 'subscriber',
    'number'  => $per_page,
    'offset'  => $offset,
    'orderby' => 'registered',
    'order'   => 'DESC',

    // Search by email domain
    'search'         => '*@gmail.com',
    'search_columns' => [ 'user_email' ],

    // Custom meta condition
    'meta_query' => [
        'relation' => 'AND',
        [
            'key'     => 'onboarding_complete',
            'value'   => '1',
            'compare' => '=',
        ],
        [
            'key'     => 'company',
            'compare' => 'EXISTS', // field must exist and be non-empty
        ],
    ],

    // Registered in the last 30 days
    'date_query' => [
        [
            'after'     => '30 days ago',
            'inclusive' => true,
            'column'    => 'user_registered',
        ],
    ],
] );

$users = $user_query->get_results();
$total = $user_query->get_total();
$pages = ceil( $total / $per_page );

foreach ( $users as $user ) {
    printf(
        '%s (%s) — registered %s',
        esc_html( $user->display_name ),
        esc_html( $user->user_email ),
        esc_html( $user->user_registered )
    );
    $company = get_user_meta( $user->ID, 'company', true );
    echo esc_html( $company ? " — $company" : '' ) . "
";
}

// ── Get only user IDs (more efficient when you only need IDs) ─────────
$id_query = new WP_User_Query( [
    'role'   => 'administrator',
    'fields' => 'ID', // return only IDs, not full WP_User objects
] );
$admin_ids = $id_query->get_results(); // [1, 3, 7]

// ── Count users by role ───────────────────────────────────────────────
$count_users = count_users(); // ['avail_roles' => ['administrator' => 2, 'subscriber' => 145, ...]]
$subscriber_count = $count_users['avail_roles']['subscriber'] ?? 0;

NOTE: WP_User_Query does not have a no_found_rows equivalent — it always counts total results when count_total is true (the default), which adds a SQL_CALC_FOUND_ROWS to the query. Set 'count_total' => false if you don't need pagination and want a faster query. The search argument uses a LIKE comparison against the columns listed in search_columns (default: login, email, URL, display name). The wildcard * in the search string maps to SQL % — so '*@gmail.com' becomes LIKE '%@gmail.com'. For multisite, add 'blog_id' => get_current_blog_id() to scope results to the current site only.