Move a player's entire history to a new UUID — across every plugin on your network — in one command.
When a player's UUID changes — cracked-to-premium, an offline-mode network adopting online UUIDs, a forced account merge — their data doesn't follow. LuckPerms still keys their rank under the old UUID. Their Essentials home, their CoreProtect logs, their tamed wolves in the world file, their PlayerPoints balance: all stranded under an identity that no longer logs in. The player rejoins as a stranger.
accounts rewrites every one of those references from the old UUID to the new one, in a single
network-wide operation, and tells you when it's done so the player can be let back in safely.
Every plugin stores a UUID its own way, and "find and replace the UUID" quietly misses most of them:
- SQL plugins store it as a dashed string, or 32 undashed hex chars, or a raw
BINARY(16)blob. A migration that only rewrites dashed strings silently skips the other two — balances and ranks just don't move, with no error. - Flat-file plugins key the player by the filename (
<uuid>.yml), not by anything inside the file. - The world itself holds UUIDs in NBT — tamed pets, player heads, boss-bar viewers — and Minecraft
has encoded those three different ways across its history (a 4-int array since 1.16, a dashed string
before it, and a
Most/Leastlong pair before that). A world from any version can hold any of them. - Vanilla JSON (
ops.json,whitelist.json,banned-players.json,usercache.json) is its own format again.
accounts handles all of these as first-class module types, so "rewrite this player's identity"
actually means everywhere, not just the easy half.
Honest scope.
accountsis an engine plus admin-configured templates, not magic. You tell it which databases, files, and worlds to touch (a few lines of YAML per plugin — most are shipped ready to copy). It does the cross-encoding, cross-instance, transactional rewrite. It does not auto-discover your plugins, and it does not invent a schema it wasn't told about. Verify the template against your install before you run it on live data — the shipped ones say where their schema came from.
- One engine, five storage types. SQL (
sql), flat files (file), Minecraft world NBT (world), vanilla server JSON (json), and community-supplied custom types via an SPI — all driven by the same migrate call. - All three SQL UUID encodings. Per column you pick
dashed(default),undashed, orbinary; the value is bound the same way the owning plugin would write it, soBINARY(16)columns migrate instead of being skipped. - The NBT world-data capstone. A scan-all rewriter walks the entire NBT tree of region and
.datfiles and rewrites the old UUID in all three historical encodings, so vanilla, modded, and datapack tags from 1.8 onward are caught — no fixed tag list to fall behind. On Spigot, in-memory world objects (loaded pets, heads) are rewritten live so the next world save can't overwrite the on-disk change. - Network-wide and reliable. Multi-instance coordination over Redis makes a migration durable (survives a restart), idempotent (safe to replay), atomic per database (commit-or-rollback), and catch-up capable (an instance that was down applies what it missed on boot).
- A completion barrier.
isComplete(from, to)is true only once every live instance has applied the migration — so a login gate (e.g. Nyx) can hold the player out until their data has actually arrived everywhere. - Ten ready-to-use plugin templates. LuckPerms, EssentialsX, CoreProtect, Towny (SQL + flatfile), GriefPrevention, PlayerPoints, CMI, plus vanilla world and JSON. Copy, point at your database/folder, enable.
- Three platforms, one command. Spigot, BungeeCord, and Velocity all expose
/accounts migrate <fromUuid> <toUuid> [username]and ship the same module engine.
| Platform | Runs the engine | Admin command | Notes |
|---|---|---|---|
| Spigot/Paper | ✅ | ✅ | Also rewrites live in-memory world data when a world module is enabled. Java 8, 1.8–latest. |
| BungeeCord | ✅ | ✅ | Proxy-side; exposes the in-process MigrationService API for other plugins. |
| Velocity | ✅ | ✅ | Proxy-side; Java 17. Same API. |
These live in available-modules/ — copy the one you need into your server's
plugins/Accounts/modules/ folder and adjust the connection details.
| Plugin | Template | Type | UUID storage | Verified against |
|---|---|---|---|---|
| LuckPerms | luckperms.yml |
sql | dashed string | LuckPerms 5.x schema |
| EssentialsX | essentialsx.yml |
file | <uuid>.yml filename |
userdata layout |
| CoreProtect | coreprotect.yml |
sql | dashed string | Database.java (v23.x) |
| Towny (SQL) | towny-sql.yml |
sql | dashed string | TownySQLSource.java |
| Towny (flatfile) | towny-flatfile.yml |
file | <uuid>.txt filename |
TownyFlatFileSource.java |
| GriefPrevention | griefprevention.yml |
sql | dashed string (name/owner cols) |
DatabaseDataStore.java |
| PlayerPoints | playerpoints.yml |
sql | dashed string | migration _1_Create_Tables.java |
| CMI | cmi.yml |
sql | TEXT (unverified — confirm encoding) |
runtime SQL from issue #1306 |
| Vanilla world | world.yml |
world | NBT (all 3 encodings) | — |
| Vanilla JSON | vanilla-json.yml |
json | ops/whitelist/bans/usercache | — |
Anything not on this list is a few lines of YAML away — see Writing a module template. For a plugin with an exotic storage format, ship a jar module (SPI guide).
git clone https://github.com/albemiglio/accounts.git
cd accounts
mvn -q clean packageThe platform jars land in spigot/target/, bungee/target/, and velocity/target/. Drop the one for
your server into its plugins/ folder.
The engine coordinates over Redis (required, even for a single instance — see
Multi-instance setup). Edit plugins/Accounts/config.yml:
redis:
host: localhost
port: 6379
password: ""
# Folder (under this plugin's data directory) holding one YAML per database/folder/world to migrate.
modules-dir: modulesCopy a shipped template into the modules folder. To migrate LuckPerms data, for example:
cp available-modules/luckperms.yml plugins/Accounts/modules/luckperms.yml# plugins/Accounts/modules/luckperms.yml
name: luckperms
platform: SPIGOT
type: sql
enabled: true
database:
type: sqlite
database: plugins/LuckPerms/luckperms-sqlite.db
replacers:
- table: luckperms_players
column: uuid
- table: luckperms_user_permissions
column: uuidVerify before you run. Open one row of the target table and confirm the UUID is stored the way the template assumes (dashed string here). If it's undashed or
BINARY(16), addformat: undashed/format: binaryto that replacer. Running blind on the wrong encoding silently migrates nothing.
Restart the server (so the module loads), then run the command as an operator:
/accounts migrate 069a79f4-44e9-4726-a5be-fca90e38aaf5 c06f8906-4c8a-4911-9c29-ea1dbd1aab82 Steve
That rewrites every uuid row in both LuckPerms tables from the old UUID to the new one, in a single
transaction (all-or-nothing), then broadcasts it to every other instance on the network so they apply it
too. When isComplete(from, to) returns true, the data has landed everywhere.
A full worked example — including the world file and a second server — is in docs/quickstart-example.md.
/accounts migrate <from> <to> another plugin (e.g. Nyx)
│ │ MigrationService API
▼ ▼
┌──────────────────────────────────────────────────┐
│ AccountsEngine (per instance) │
│ migrate() · isComplete() · isInProgress() │
└───────────────┬──────────────────┬───────────────┘
│ │
BroadcastMigrationService RedisMigrationSubscriber
(record → apply → publish) (apply broadcasts from peers)
│
InstanceMigrator ── idempotent, per-instance "have I applied this?"
│
┌──────────────┬───┴────────┬──────────────┬──────────────┐
SQL module file module world module json module jar module (SPI)
(ColumnReplacer (rename (NBT scan-all (vanilla (community
+ UuidCodec) <uuid>.ext) rewriter) json) ModuleProvider)
│
Redis: durable record · expected/applied/failed sets · pub-sub · instance heartbeats
AccountsEngineis the single handle a platform plugin holds: build on enable,migrate()from the command or the API, askisComplete()for an unlock gate,close()on disable.BroadcastMigrationServicerecords the migration durably, applies it locally, and publishes it to peers. On boot it recovers anything it missed while down.InstanceMigratorruns the enabled modules for one instance, exactly once (idempotent), and never lets a module failure crash the caller — it records the failure and retries later.- Modules are the storage adapters; each
Module.execute()wraps its work in a per-database transaction. New types arrive via theModuleProviderSPI without touching the core.
Full walkthrough: docs/how-the-engine-works.md.
A plugin on the same proxy (this is how Nyx drives it) can trigger a
migration in-process, instead of via the command, through the MigrationService interface in the api
module:
MigrationService accounts = /* look up the accounts plugin */;
accounts.migrate(oldUuid, newUuid, username);
if (accounts.isMigrationInProgress(oldUuid, newUuid)) {
// hold the login until the transfer finishes everywhere
}Ship the api jar as a provided dependency — never shade it, or the two copies get different
classloader identity and the cast fails.
- How the migration engine works — durability, idempotency, the completion barrier, what "atomic per database" really guarantees.
- Writing a module template — the full YAML schema, every type,
the
format: dashed|undashed|binaryoption, and the jar-module SPI. - The plugin-template catalog — every shipped template, what it touches, and how to verify it against your install.
- Multi-instance (Redis) setup — why Redis is required, the keys it uses, and how the completion barrier works across servers.
- Quickstart example — one real end-to-end migration, including the world.
- Live-test rig — a runnable
docker compose+ script that migrates a real player across a real MySQL and a flat file and asserts the result. The end-to-end proof, in the repo.
- Redis is required, even for a single server: the engine always coordinates through it.
- Back up first, then diagnose. A UUID migration rewrites player data in place, so take a backup.
Then run
/accounts diagnose <uuid>with a real player who has data on this server: read-only, it checks that every loaded module finds that player where it expects and in the encoding it assumes, and flags any template whoseformatis wrong or whose table/path is missing — so you fix it before migrating instead of discovering a silent miss afterwards. Safe to run on a live server. - H2 backends migrate offline. While the owning plugin runs it holds a lock on the
.mv.dbfile, so migrate H2 stores with that server stopped (a few plugins open H2 inAUTO_SERVERmode and can be done live — the engine connects if the file allows it). Each H2 template pins the exact H2 version its plugin bundles, and the engine refuses a file written by a different version family. SQLite/MySQL migrate live. - World/NBT is fully handled — back up anyway. The engine rewrites both the on-disk region/entity NBT
and (on Spigot) the live in-memory objects, so tamed pets, placed heads and boss bars all follow the new
uuid. It matches the uuid in every encoding, so there's no
formatto get wrong — just run it with a backup, ideally at low activity.
PRs welcome — new plugin templates especially. See CONTRIBUTING.md and the Code of Conduct. Security issues: SECURITY.md.
Released under the MIT License © 2023 Alberto Migliorato — permissive, so you can embed
accounts in closed- or open-source plugins alike.
