WP_Query se lleva la mayoría de los tutoriales. WP_User_Query es lo que realmente ejecutan los sitios de membresía, los directorios de personal y las plataformas comunitarias.
Si solo necesitas una cuadrícula estática de “Nuestro equipo”, una consulta ligera en la plantilla gana a otro plugin de membresía que carga sus propias plantillas, rutas REST y CSS en cada petición. Esta guía se centra en ese problema: filtrar usuarios con role__in, meta_query y paginación sin quemar la base de datos ni filtrar nombres de login.
Referencia oficial: WP_User_Query en developer.wordpress.org. Cada valor por defecto y cada comportamiento descrito abajo se ha comprobado contra el código fuente de la clase en wordpress-develop el 12 de octubre de 2026.
get_users() vs WP_User_Query
get_users() es un wrapper fino sobre WP_User_Query. Acepta el mismo array de argumentos y devuelve un array de objetos WP_User (o subconjuntos de campos cuando pasas fields).
Usa get_users() cuando quieras una lista corta y nada más: por ejemplo cinco editores para un widget de firmas.
Usa WP_User_Query cuando necesites:
get_results()másget_total()para directorios paginados- inspección de
$query->requestmientras depuras SQL - la misma forma de argumentos que
get_users(), pero con ciclo de vida explícito del objeto de consulta
Hay una diferencia de comportamiento que conviene conocer. get_users() fuerza count_total a false antes de construir la consulta, así que nunca ejecuta el conteo adicional y no tiene ningún total que darte. Es el valor correcto para un widget y el incorrecto para un directorio paginado.
En ambas APIs el array de argumentos es el contrato. Todo lo que sigue funciona con cualquiera de las dos formas; los ejemplos usan WP_User_Query porque los directorios necesitan totales.
Qué devuelve WP_User_Query con los argumentos por defecto
Los valores por defecto deciden casi todo el coste de una consulta descuidada, así que conviene conocerlos:
| Argumento | Por defecto | Qué significa en la práctica |
|---|---|---|
number | sin límite | Se devuelven todos los usuarios que coinciden. El docblock pide usar -1 (todos) con cautela en sitios grandes. |
paged | 1 | Solo se usa cuando number es mayor que cero. |
orderby | login | Orden por user_login, que rara vez es lo que quiere una lista pública. |
fields | all | Objetos WP_User completos, con todo el user meta precargado mediante cache_users(). |
count_total | true | Añade SQL_CALC_FOUND_ROWS y lanza una segunda consulta SELECT FOUND_ROWS(). |
blog_id | sitio actual | Los filtros de rol y de pertenencia se aplican solo al sitio actual. |
cache_results | true | Los resultados se guardan en la cache de objetos (añadido en WordPress 6.3). |
De ahí salen dos consecuencias. Una consulta sin number en un sitio con muchos usuarios los carga a todos. Y una consulta que nunca pagina debería pasar 'count_total' => false, porque el docblock indica que desactivarlo mejora el rendimiento cuando no hace falta paginar.
La clase además se niega a ejecutarse demasiado pronto: desde WordPress 6.1.1, una consulta de usuarios lanzada antes del hook plugins_loaded dispara un aviso _doing_it_wrong().
Ejemplo de WP_User_Query con role__in para una página de equipo
Editores y autores, ordenados por nombre para mostrar, doce por página:
$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 ) ) {
// Una sola consulta para el meta de los doce usuarios, no doce consultas.
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>';
}Notas que importan en producción:
role__incoincide con cualquiera de los roles listados. Prefiérelo a varias consultas conrole.fieldsdevuelve filasstdClassen lugar de objetosWP_Usercompletos, lo que recorta memoria cuando solo necesitas IDs y nombres.- Con un array en
fieldsWordPress no precarga el user meta, así que la llamada aupdate_meta_cache()de arriba es lo que mantiene las lecturas de biografía en una sola consulta. Confields => 'all'la clase hace esa precarga por ti mediantecache_users(). - El número de página llega en un parámetro propio,
team_page. En una página estática la consulta principal usapage, nopaged, así que leerget_query_var( 'paged' )ahí devuelve en silencio el valor por defecto.
Cómo funcionan los filtros role, role__in y capability
Los tres argumentos de rol se parecen y se comportan distinto:
roleacepta una cadena o un array, y el array es una lista AND: el usuario debe tener cada rol listado.'role' => [ 'editor', 'author' ]no devuelve casi a nadie en un sitio estándar.role__ines la lista OR: al menos uno de los roles.role__not_inexcluye a los usuarios que tengan cualquiera de los roles listados.
Por debajo ninguno toca una tabla de roles, porque WordPress no la tiene. Los roles viven en un array serializado dentro de la fila de user meta {prefix}capabilities, y cada cláusula de rol se convierte en una comparación LIKE '%"editor"%' sobre esa fila. Un filtro de rol ya es un join sobre wp_usermeta antes de que añadas un solo meta_query.
Desde WordPress 5.9 existen también capability, capability__in y capability__not_in. Traducen capacidades a los roles que las conceden, más las capacidades añadidas directamente a un usuario. El docblock advierte que no funcionan con capacidades que no estén guardadas en la base de datos o que se filtren mediante map_meta_cap. Trátalas como un atajo para capacidades primitivas como edit_posts, no como una comprobación de permisos.
Cómo paginar WP_User_Query con get_total()
Los argumentos de paginación son number (tamaño de página) y paged (base 1). Tras la consulta, get_total() es el conteo de coincidencias en todas las páginas, no solo en la actual.
$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();
// Construye enlaces con add_query_arg( 'udir_page', $n ) y escapa las URLs.Antipatrones a evitar:
- Pedir
'number' => -1(u omitirlo) y recortar conarray_sliceen PHP - Lanzar una segunda consulta completa solo para contar filas cuando
get_total()ya hizo el trabajo - Meter valores no confiables de
$_GETenorderbysin una lista blanca estricta
Dos detalles del código fuente pillan a mucha gente. Si pasas offset, gana él: la clase construye LIMIT offset, number e ignora paged. Y con fields => 'ID' el resultado es un array plano de IDs en forma de cadena, salido de $wpdb->get_col(), no objetos, así que conviértelos antes de comparaciones estrictas.
En directorios públicos usa una query var dedicada (como arriba) para no chocar con el bucle principal paged del blog.
Cómo filtrar usuarios con meta_query en WordPress
meta_query es donde los directorios se vuelven útiles, y donde aparece el coste de MySQL. Ejemplo: suscriptores en Madrid, solo perfiles públicos, con PHP listado en un meta de habilidades.
$args = [
'role' => 'subscriber',
'number' => 20,
'paged' => 1,
'fields' => [ 'ID', 'display_name' ],
'meta_query' => [
'relation' => 'AND',
[
'key' => 'city',
'value' => 'Madrid',
'compare' => '=',
],
[
'key' => 'is_public_profile',
'value' => '1',
'compare' => '=',
],
[
'key' => 'skills',
'value' => 'PHP',
'compare' => 'LIKE', // funciona con arrays serializados; caro a escala
],
],
];
$directory = new WP_User_Query( $args );Reglas prácticas para meta_query sobre usuarios:
- Prefiere compares exactos
=sobre claves dedicadas (city,is_public_profile) frente aLIKEsobre blobs serializados. LIKE 'PHP'es una búsqueda de subcadena, así que también encuentra una habilidad llamadaPHPUnit. Es un error de exactitud, no solo de velocidad.- Guarda las habilidades multivalor como metas booleanas separadas (
skill_php=1) si las filtras a menudo. Así OR/AND se convierten en igualdades amigables con índices. Otra opción es una filaskillspor valor, escrita conadd_user_meta( $user_id, 'skills', 'php' ), que admite claves duplicadas, y filtrada después con'compare' => '='o'IN'. - Combina
relationcon cuidado. Los grupos anidados son válidos, pero cada cláusula es otro join sobrewp_usermeta. - Empareja siempre los filtros públicos con un flag de visibilidad para que los perfiles privados no aparezcan en listados no autenticados.
Por qué importan las claves exactas se ve en el esquema del núcleo. La tabla wp_usermeta que crea wp-admin/includes/schema.php tiene una clave primaria, un índice sobre user_id y un índice de prefijo sobre meta_key. No hay índice sobre meta_value, que es una columna longtext. MySQL puede acotar el join a las filas de una clave, pero tiene que leer todas esas filas para comparar valores.
Cómo funcionan search y search_columns en WP_User_Query
El argumento search es fácil de usar mal porque no se comporta como un buscador.
- Sin comodines es una coincidencia exacta.
'search' => 'ana'encuentra al usuario cuyo login es exactamenteana. Añade*al principio, al final o en ambos lados ('*ana*') para obtener unLIKEcon%. search_columnsaceptaID,user_login,user_email,user_url,user_nicenameydisplay_name.IDsiempre se compara con=.- Cuando
search_columnsestá vacío, WordPress adivina: un término con@busca enuser_email, un término numérico enuser_logineID, una URL enuser_url, y cualquier otra cosa en login, URL, correo, nicename y nombre para mostrar a la vez.
Ese último valor por defecto es el peligroso en un directorio público. Un visitante que escribe parte de una dirección de correo en tu buscador puede confirmar que esa dirección pertenece a un miembro. Para la búsqueda pública, fija siempre tú las columnas:
$term = sanitize_text_field( wp_unslash( $_GET['q'] ?? '' ) );
$args = [
'role__in' => [ 'subscriber' ],
'search' => '*' . $term . '*',
'search_columns' => [ 'display_name' ],
'number' => 20,
'fields' => [ 'ID', 'display_name' ],
];El filtro user_search_columns permite a un plugin cambiar la lista de columnas en todas las búsquedas de usuarios del sitio, así que revísalo cuando los resultados no cuadren.
Cómo acelerar WP_User_Query en directorios grandes
Las consultas de usuarios parecen baratas hasta que el directorio es público y filtrado.
Cómo limitar los campos devueltos por WP_User_Query
Por defecto WordPress hidrata más datos de los que necesita una cuadrícula de tarjetas. Pasa fields pronto:
$args = [
'role' => 'subscriber',
'number' => 100,
'fields' => [ 'ID', 'display_name', 'user_email' ],
];Quita user_email de las plantillas públicas. Resérvalo solo para herramientas de personal detrás de comprobaciones de capacidades.
Mantén la lista corta por un segundo motivo: cuando un array de fields nombra más de tres campos, WP_User_Query desactiva su propia cache de resultados para esa consulta.
Cómo contar usuarios sin cargar sus perfiles
$query = new WP_User_Query(
[
'role' => 'subscriber',
'number' => 1,
'fields' => 'ID',
]
);
$count = $query->get_total();number => 1 deja el resultado en un solo ID mientras get_total() sigue informando del conteo completo.
Para totales por rol sin filtros existe count_users(). No es gratis: no hay ningún contador de roles guardado detrás. Con la estrategia por defecto, time, lanza una consulta agregada sobre todas las filas {prefix}capabilities con un LIKE por rol, y su docblock la describe como intensiva en CPU y válida para unos 10^7 usuarios. Si solo necesitas el número total de cuentas, get_user_count() lee una opción guardada, user_count. En los sitios que wp_is_large_user_count() considera grandes (más de 10.000 usuarios por defecto) ese valor lo refresca un evento cron dos veces al día, así que puede ir con retraso.
Cómo cachear los resultados de WP_User_Query
Desde WordPress 6.3 la clase cachea por ti. Con cache_results en true, guarda los IDs del resultado y el total en el grupo de cache de objetos user-queries, con una clave formada por el MD5 del SQL generado. La entrada se sala con la marca de tiempo last-changed de users, y el núcleo actualiza esa marca en added_user_meta, updated_user_meta, deleted_user_meta, add_user_role, set_user_role y remove_user_role. Cualquier edición de perfil invalida todas las consultas de usuarios cacheadas.
Esa cache solo sobrevive entre peticiones cuando el sitio tiene una cache de objetos persistente (Redis o Memcached). Sin ella dura una sola carga de página.
Si añades tu propia capa encima, por ejemplo un transient con el HTML renderizado, usa como clave un hash de los filtros e invalídalo desde los mismos hooks. No cachees HTML que incluya campos condicionados por capacidades para visitantes anónimos. Y saca las escrituras frecuentes de meta del camino de la petición: un plugin que actualiza un meta de “última visita” en cada página vista mueve la marca de users en cada visita y deja la cache integrada sin nada que servir.
Cuándo pasar un directorio de miembros a Elasticsearch
Los joins sobre wp_usermeta no escalan como un motor de búsqueda. Cuando los filtros se multiplican (ciudad + especialidad + idioma + disponibilidad) y la audiencia es grande, mueve la proyección buscable a Elasticsearch (ElasticPress o un índice propio) o a una tabla de directorio dedicada con índices reales. Conserva WP_User_Query para la hidratación final de una lista pequeña de IDs.
La clase tiene un hook justo para esto. Devolver un array distinto de null desde el filtro users_pre_query se salta el SQL por completo; el docblock pide a esos filtros que rellenen la propiedad total_users para que la paginación siga funcionando.
Cómo evitar lecturas N+1 de avatar y user meta
Dentro del bucle, get_avatar() y las llamadas repetidas a get_user_meta() se multiplican. Precarga el meta de toda la página con update_meta_cache( 'user', $ids ), que carga todas las filas de meta del conjunto de IDs en una consulta, como en el ejemplo de la página de equipo. Con perfiles muy anchos, una fila de directorio desnormalizada escrita al guardar el perfil sale todavía más barata.
Quién puede listar usuarios con la REST API de WordPress
Tu plantilla puede estar cerrada mientras /wp-json/wp/v2/users sigue respondiendo. Lo que devuelve depende del visitante, y las reglas están en WP_REST_Users_Controller, resumidas en la referencia del endpoint de usuarios:
- Un visitante sin la capacidad
list_users(por defecto solo la tienen los administradores) solo ve usuarios que han publicado entradas en un tipo de contenido expuesto a la REST API. Los miembros que nunca publicaron quedan fuera de la lista anónima. - En el contexto por defecto,
view, un objeto de usuario llevaid,name,url,description,link,slugyavatar_urls.username,email,rolesyregistered_datesolo existen en el contextoedit, que exigelist_users. - Filtrar por
rolesocapabilities, ordenar poremailoregistered_datey pedircontext=editdevuelven un error de autorización sinlist_users. - Para esos visitantes una búsqueda queda fijada a
ID,user_login,user_nicenameydisplay_name. El parámetrosearch_columns, añadido en WordPress 6.8, no puede ampliar esa lista. per_pagevale 10 por defecto y tiene un máximo de 100, y la respuesta lleva las cabecerasX-WP-TotalyX-WP-TotalPages.
El campo slug es el punto débil. wp_insert_user() construye user_nicename a partir del login cuando no se indica uno, pasando los primeros 50 caracteres de user_login por sanitize_title(). En la mayoría de las cuentas el slug público es, por tanto, el login o algo muy parecido. El mismo nicename aparece en las URLs de los archivos de autor.
Dos filtros te dan el control. rest_user_query recibe los argumentos de WP_User_Query de cada petición de lista, así que puedes añadir ahí un meta_query de visibilidad. Si el sitio no tiene uso público para el endpoint, desregístralo solo para visitantes anónimos, porque el editor de bloques lee usuarios por la misma ruta cuando hay editores con sesión iniciada:
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;
}
);Cómo evitar la enumeración de usuarios en un directorio
Un directorio de miembros es una lista pública de cuentas. Trátalo como tal.
- Nunca imprimas
user_loginen el marcado del frontend. Usadisplay_nameo un apodo público dedicado. Recuerda queuser_nicenamese deriva del login por defecto, así que no es un sustituto seguro. - Nunca imprimas
user_emailsalvo que el visitante esté autenticado y autorizado a verlo. - Los archivos de autor (
/?author=1) siguen filtrando logins en muchos sitios. Si el producto no necesita archivos de autor públicos, redirígelos:
add_action(
'template_redirect',
static function () {
if ( is_author() ) {
wp_safe_redirect( home_url( '/' ), 301 );
exit;
}
}
);- Escapa todo lo que salga de PHP:
esc_html(),esc_url(),esc_attr()en atributos. - Condiciona por capacidades los directorios solo para administradores (
list_users) en lugar de confiar en la oscuridad de la URL. - Limita las
search_columnspúblicas adisplay_name, como se ve arriba.
Directorio de miembros y datos personales según el RGPD
Para miembros en la UE o el EEE, sí. Los nombres, fotos, ciudades y habilidades de personas identificables son datos personales según el artículo 4, apartado 1, del Reglamento General de Protección de Datos. El artículo 25, apartado 2, añade una regla que encaja directamente con tus argumentos de consulta: por defecto, los datos personales no deben ser accesibles a un número indeterminado de personas sin la intervención del interesado.
En código eso significa:
- Las cuentas nuevas empiezan con
is_public_profile=0hasta que el miembro lo active. - Flags separados para “mostrar teléfono” o “mostrar correo”: un perfil público no es consentimiento para publicar datos de contacto.
- Cada consulta pública incluye la cláusula de visibilidad en su
meta_query, y también cualquier filtrorest_user_query. - Las bases de datos de staging con miembros reales se anonimizan antes de que nadie fuera del equipo reciba una copia.
WP_User_Query solo filtra. No sustituye al consentimiento, a una finalidad declarada ni a una política de borrado, pero sin el flag de visibilidad ningún aviso de privacidad impedirá que el perfil aparezca.
Plugin de membresía o directorio propio con WP_User_Query
Los plugins de membresía se ganan su sitio con el registro, los niveles de pago y la mensajería. El listado público es un problema más pequeño, y es el que se ejecuta en cada petición anónima. Deja el plugin para el flujo de cuenta y construye tú el listado cuando tenga que ser rápido o muy filtrado.
Si ya dependes de la cuadrícula de un plugin, mídela antes de aceptarla: abre el listado con Query Monitor y comprueba que la consulta de usuarios tiene LIMIT, pide solo los campos que muestran las tarjetas y no dispara una consulta de meta por tarjeta.
Cómo crear una plantilla de directorio de miembros en WordPress
Un directorio público durable suele tener cuatro capas:
- Sanitización de entrada: claves de orden en lista blanca, página entera, slug de ciudad normalizado.
- Consulta:
role__in/meta_query/fields/number/paged. - Presentación: tarjetas solo con IDs y campos de visualización seguros.
- Invalidación de cache: ligada a las claves meta que afectan a la visibilidad.
Deja la UI del plugin para el alta de cuentas y la mensajería si necesitas esas funciones. Deja el listado y el filtrado en tu tema o en un mu-plugin pequeño que controles tú. Esa separación es lo que mantiene el directorio rápido cuando marketing añade otro filtro el trimestre siguiente.
WP_User_Query en multisite con roles por sitio
En una red WordPress multisite la tabla de usuarios es compartida. WP_User_Query sigue funcionando, pero los filtros de rol son por sitio. Un usuario que es Editor en el sitio A puede no tener rol en el sitio B. Para un directorio de personas a escala de red:
- Prefiere
blog_idcuando debas acotar roles a un solo sitio. - No asumas que
role__in => array( 'author' )significa las mismas personas en cada blog. - Para listas de personal entre sitios, guarda un flag de meta de red (por ejemplo
wpp_show_in_directory) y consulta eso en lugar de roles solos.
El código fuente explica por qué. En multisite, incluso una consulta sin argumentos de rol añade una cláusula EXISTS sobre la clave {prefix}capabilities del sitio actual, así que solo vuelven los miembros de ese sitio. Pasar 'blog_id' => 0 se salta las cláusulas de rol y de pertenencia y consulta toda la red.
Las copias de staging de multisite a menudo recortan usuarios. Mide el coste de la consulta sobre un dump que coincida con la cardinalidad de producción, o tu plan de meta_query parecerá correcto hasta el día del lanzamiento.
Cómo probar el SQL de WP_User_Query con Query Monitor
Antes de fusionar:
- Activa Query Monitor en staging.
- Abre la URL del directorio con la combinación de filtros más pesada que use marketing.
- Confirma una consulta primaria de usuarios, no una consulta por tarjeta para el meta.
- Guarda
$query->requesten las notas del PR para que la siguiente persona detecte regresiones.
Si el directorio debe soportar búsqueda de texto libre sobre nombres para mostrar, usa un índice de búsqueda dedicado o un argumento search bien acotado en WP_User_Query. No hagas LIKE sobre user_email en un endpoint público. Registra las peticiones lentas del directorio con el payload de filtros para poder reproducirlas después.
¿Necesitas revisar un directorio WordPress o una consulta de membresía antes de producción? Habla con un desarrollador WordPress.
Checklist de WP_User_Query para un directorio de miembros
- Usa
fieldspara no hidratar objetos de usuario completos en cuadrículas de tarjetas, y quédate en tres campos o menos para conservar la cache integrada. - Pagina con
number+pagedy confía enget_total()para la matemática de páginas; pasacount_total => falsecuando no haya paginación. - Prefiere claves meta exactas frente a
LIKEsobre arrays de habilidades serializados. - Precarga el meta con
update_meta_cache()antes del bucle. - Cachea listas de IDs o marcado cuando los filtros sean estables; invalida al escribir perfiles.
- Renderiza solo
display_name; oculta logins, nicenames y correos del HTML público y de la búsqueda pública. - Comprueba qué devuelve
/wp/v2/usersa un visitante anónimo. - Mide el SQL (
$query->request) en staging con volumen realista de usuarios y meta antes del lanzamiento.
Los usuarios son objetivos de consulta de primera clase en WordPress. Consúltalos con la misma disciplina que ya aplicas a WP_Query.
En el lado de entrega mantenemos este tema bajo auditoría de seguridad WordPress.







