← Back to blog

How to export WordPress users, and the two things that silently break on import

The SandyWP team 8 min read

WordPress has no built-in user export. Tools > Export offers four choices, All content, Posts, Pages and Media, and none of them is Users.

The fastest honest route is WP-CLI: wp user list --format=csv for a readable list, or a dump of the users and usermeta tables when you need the accounts to work on the other side.

Every guide on the first page for this answers with a plugin. None of them says what a user account actually consists of, which is why user imports so often arrive with no roles or with nobody able to log in.

What Tools > Export gives you, and why it is not users

The export screen does contain author data, which is what misleads people. It is produced by wxr_authors_list() in wp-admin/includes/export.php, and it runs this query:

SELECT DISTINCT post_author FROM wp_posts WHERE post_status != 'auto-draft'

Read that condition carefully. The export only includes users who have authored at least one post.

Every subscriber, customer, member and commenter is absent from the file, because they have no row in wp_posts. On a membership or WooCommerce site that is usually the entire user base.

For the authors it does include, you get six fields:

<wp:author>
  <wp:author_id>2</wp:author_id>
  <wp:author_login>jordan</wp:author_login>
  <wp:author_email>[email protected]</wp:author_email>
  <wp:author_display_name>Jordan</wp:author_display_name>
  <wp:author_first_name>Jordan</wp:author_first_name>
  <wp:author_last_name>Reyes</wp:author_last_name>
</wp:author>

No role, no password, no registration date, no user meta. The importer uses these six fields only to offer you an author mapping dropdown, which is their actual purpose.

Our guide to exporting a WordPress site covers the rest of what WXR leaves out. This post is about the user half.

A user account is two tables, and they are not where you expect

One user is one row in wp_users and many rows in wp_usermeta.

wp_users holds user_login, user_pass (a hash), user_email, user_nicename, user_url, user_registered and display_name. That is the whole table.

Everything else about the user lives in wp_usermeta: first and last name, nickname, biography, admin colour scheme, and crucially the role.

Both tables are special. In wp-includes/class-wpdb.php core declares:

public $global_tables = array( 'users', 'usermeta' );

Being global means these two tables are shared across a multisite network rather than duplicated per site. It also means a per-site database export that filters by table prefix can miss them, depending on how it is written.

The WP-CLI route, which is the one to prefer

If you want a list to read, filter or hand to someone, export the fields you need:

wp user list --format=csv --fields=ID,user_login,user_email,display_name,roles,user_registered > users.csv

roles is a real field here, so this is the one command that gets you accounts and their roles in a single readable file.

To narrow it down, filter by role:

wp user list --role=subscriber --format=csv --fields=user_login,user_email,roles

On the receiving site, wp user import-csv users.csv creates or updates accounts. The documented columns are user_login, user_email, display_name and role, and it updates users that already match by login or email unless you pass --skip-update.

This is the right tool when you want accounts recreated. It is the wrong tool when you need people's existing passwords to keep working, for the reason below.

Our SSH and SFTP access ships wp preinstalled on every sandbox, so you can try the whole import sequence before running it anywhere that matters.

Trap one: the role meta key contains your table prefix

A user's role is not a column. It is a row in wp_usermeta whose key is built at runtime.

In wp-includes/class-wp-user.php, the _init_caps() method sets it like this:

$this->cap_key = $wpdb->get_blog_prefix( $this->site_id ) . 'capabilities';

So on a default install the key is wp_capabilities. On an install whose prefix is xyz_, the key is xyz_capabilities, and the same applies to the legacy user_level meta.

Copy wp_usermeta rows between two sites with different table prefixes and every role disappears. The rows arrive intact, WordPress looks for a key that is not there, finds nothing, and treats each account as having no role at all.

No error is logged, because nothing failed. The users exist, they can log in, and they can do nothing.

If you hit this, the fix is to rewrite the key rather than the data:

wp db query "UPDATE wp_usermeta SET meta_key = 'wp_capabilities' WHERE meta_key = 'oldprefix_capabilities'"

We wrote about the same prefix dependency from the other direction in testing a WordPress table prefix change. It is the single most common reason a user migration looks successful and is not.

Trap two: importing a password hash hashes it again

wp_users.user_pass holds a hash, not a password. It is tempting to carry that hash across so people keep their existing logins, and through a CSV import that does not work.

Core's own docblock for wp_insert_user() describes the field as "The plain-text user password for new users". A few lines later it does exactly that:

$user_pass = wp_hash_password( $userdata['user_pass'] );

There is no branch that detects an existing hash. Feed a hash into a user creation and you store a hash of the hash, so the original password no longer matches and nobody can log in.

The update path behaves differently, which is why this trap is intermittent. wp_update_user() only re-hashes when the submitted value differs from the stored one, so passing an unchanged hash to an existing user is harmless.

Create new users and the same value is destroyed. That asymmetry is why a CSV import of 500 accounts can leave some people able to log in and the rest not.

There are two honest options. Move the hashes at the database level, where no hashing function runs, or create the accounts without passwords and have everyone reset.

Core supports the second deliberately. Creating a user with an empty user_pass triggers a notice saying the user will need to reset their password before logging in, and wp user reset-password <user> is the command for it.

The database route, when the accounts must keep working

When you need roles, meta and working passwords to survive, copy the tables rather than the records:

wp db export users.sql --tables=wp_users,wp_usermeta

Import it on a site with the same table prefix and the accounts arrive complete, passwords included, because nothing re-hashes anything.

Two things to check before you do. User IDs come across as they are, so an existing user holding the same ID on the destination is overwritten, and post_author values on the destination that pointed at that ID now point at a different person.

That is why this route belongs on an empty or freshly cloned destination, not on a site that already has its own users.

Multisite makes this worse in a specific way

Because users and usermeta are global, a network has one user table and one set of accounts. What is per-site is the role.

get_blog_prefix() returns the base prefix for site 1, and wp_<id>_ for every other site. So one user row can carry wp_capabilities, wp_2_capabilities and wp_5_capabilities at once, one per subsite they belong to.

Export those meta rows into a single-site install and only the unnumbered key means anything. The rest are inert rows that look like data and do nothing.

If you are planning that kind of move, subdomain versus subdirectory multisite covers the surrounding decisions.

What none of these methods carry

Be honest with yourself about the scope before you promise a clean migration.

Membership plugins, LMS plugins and WooCommerce keep a great deal about a person outside those two tables. Subscriptions, orders, course progress and access rules usually live in their own custom tables or in post types.

A perfect wp_users and wp_usermeta copy can still produce customers with no orders. Exporting users is one piece of a migration, not the migration.

If what you actually want is the whole site rather than its people, our Cloner plugin copies a live site into a sandbox and rewrites URLs on the way in, which skips this entire problem.

Do the import on a throwaway copy first

Both traps in this post are silent. Neither logs an error, and both are discovered by a person trying to log in, which is the worst possible time.

So run the import once on a disposable install before the real one. Spin up a sandbox, import the file, and check three things.

Log in as one imported non-admin user, and confirm the role is right rather than assuming it from the Users screen. Then run wp user list --fields=user_login,roles and look for a blank roles column, which is trap one.

Finally, reset one password and log in again, which tells you whether trap two applies to your file.

Teams doing this repeatedly for clients tend to keep it as a rehearsal step. Our notes for agencies cover that pattern, and for upgrade testing covers the related habit of proving a change on a copy before it reaches anyone.

When exporting users is the wrong answer

If both sites are yours and the goal is to move the whole thing, do not export users at all. Migrate the site and the users come with it, prefix and hashes unchanged.

If you need user data for analysis rather than for a WordPress install, wp user list --format=csv is the end of the job. Do not reach for a migration tool to produce a spreadsheet.

And if you need people's passwords to keep working across a prefix change, change the prefix properly on one site instead of trying to repair meta keys after the fact. The second approach has more ways to go wrong than the first.