Most tutorials about storing data in a Minecraft plugin teach you how to break your server. They bundle a driver that is already there, they use the player UUID as the primary key without a word about offline mode, and they fire queries straight from the game loop. The plugin looks fine with two testers, then loses data and tanks the TPS on a busy night. Here are the three decisions that actually matter, with the code that goes with them.
What the real fleet says, not the docs
Three numbers that should drive your schema
Let us start with a measurement, because that is what is missing everywhere else. Across a sample of 62,948 servers reporting to bStats, the most deployed version is 1.21.11 at 36.4% of the fleet. Then comes 26.1.2 at 14.9%, then 26.2 at 7.4% and 1.21.4 at 7.2%. In other words: even after Minecraft moved to calendar versioning, the 1.21 family is still the majority by a wide margin.
| Share of fleet | What it means for your database | |
|---|---|---|
| 1.21.11 | 36.4% | Your default compatibility target |
| 26.1.2 | 14.9% | The sane production target on calendar builds |
| 26.2 | 7.4% | No stable Paper build, avoid in production |
| 1.21.4 | 7.2% | Still common enough to count |
Two consequences follow. First, if you target 26.2 you are targeting a version with no stable Paper build: the sane production target today is 26.1.2. Second, Java 25 is the floor for calendar versions, yet compiling your plugin against Java 21 is still the right move. That is what most of the fleet runs, and a .jar built for Java 21 loads happily on a newer runtime. The reverse is not true, and that is exactly the mistake that produces an UnsupportedClassVersionError at startup.
The third number carries the most weight, and no database tutorial mentions it: 75.1% of servers run in offline mode. We come back to it below, because it is what decides your schema.
Paper already ships the SQLite driver
Zero dependencies to add for SQLite
Here is the fact half the tutorials miss: the SQLite JDBC driver is already shipped with Paper. Nothing to bundle, nothing to declare, nothing to relocate. You open a connection and it works. HikariCP, on the other hand, is not shipped, and that single distinction changes how you write your plugin.yml.
name: PlayerData
version: 1.0.0
main: fun.example.playerdata.PlayerDataPlugin
api-version: '1.21'
folia-supported: true
# No libraries block for SQLite: Paper already ships the driver.Opening the file takes about fifteen lines. Three PRAGMA statements are worth setting immediately, and the third one surprises even experienced developers: in SQLite, foreign keys are off by default. Your ON DELETE CASCADE does nothing until you run PRAGMA foreign_keys = ON, and you have to run it on every connection.
package fun.example.playerdata;
import org.bukkit.plugin.java.JavaPlugin;
import java.io.File;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;
public final class PlayerDataPlugin extends JavaPlugin {
private Connection connection;
private PlayerStore store;
@Override
public void onEnable() {
if (!getDataFolder().exists() && !getDataFolder().mkdirs()) {
getLogger().severe("Could not create the data folder, disabling.");
getServer().getPluginManager().disablePlugin(this);
return;
}
File file = new File(getDataFolder(), "data.db");
try {
// Paper ships the driver. This call only forces its registration
// when the plugin classloader has not seen it yet.
Class.forName("org.sqlite.JDBC");
connection = DriverManager.getConnection("jdbc:sqlite:" + file.getAbsolutePath());
try (Statement st = connection.createStatement()) {
st.execute("PRAGMA journal_mode = WAL");
st.execute("PRAGMA busy_timeout = 5000");
st.execute("PRAGMA foreign_keys = ON");
}
} catch (ClassNotFoundException | SQLException e) {
getLogger().severe("Database unavailable, disabling: " + e.getMessage());
getServer().getPluginManager().disablePlugin(this);
return;
}
store = new PlayerStore(this, connection);
store.createTables();
getServer().getPluginManager().registerEvents(
new PlayerDataListener(this, store), this);
}
@Override
public void onDisable() {
if (store != null) {
store.flushBlocking();
}
if (connection != null) {
try {
connection.close();
} catch (SQLException ignored) {
// Nothing useful left to do during shutdown.
}
}
}
}Note the disablePlugin call in both failure branches. A persistence plugin that cannot open its database should shut itself down rather than carry on quietly and throw a NullPointerException on every join. Note also that listeners are registered inside onEnable: a plugin that forgets that compiles perfectly and does absolutely nothing. Method signatures live in the Paper Javadoc, and the legacy Bukkit surface on hub.spigotmc.org.
Never shade the SQLite driver
sqlite-jdbc into your .jar when Paper already provides it gives you two copies of the same driver, loaded by two different classloaders. The result is a ClassNotFoundException or a No suitable driver that makes no sense when you read the code, plus a pointlessly fat .jar. For anything that is not shipped, the libraries: block in plugin.yml replaces shading: you declare the dependency and the server downloads it at startup. It is documented on docs.papermc.io.In offline mode, a UUID is not an identity
The real trap, and it hits three servers out of four
Every tutorial writes the same line: player_uuid TEXT PRIMARY KEY. That is correct on an online-mode server, where the UUID comes from Mojang and never changes. But 75.1% of servers run in offline mode, and there the UUID is not assigned by Mojang: it is derived from the player name. The name is the source of truth, the UUID is only a digest of it. The behaviour of the online-mode setting is described on the official wiki.
Two entirely ordinary scenarios then wipe out your data. A player renames: the UUID changes, they come back with a brand new identity, and their economy and progress sit orphaned in the table. Or the owner flips the server to online mode, usually because they are adding a proxy or want names to be verified by Mojang at last. On that day every UUID changes at once and not a single row in the database matches a known player any more. Nobody deleted anything, and yet everything is gone.
The symptom you will actually see
package fun.example.playerdata;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.util.Locale;
public final class Identity {
private Identity() {
}
/**
* In online mode the UUID comes from Mojang and survives a name change.
* In offline mode it is derived from the name, so it changes with it.
* Keep both columns, and never key your other tables on either one.
*/
public static String offlineKey(Player player) {
return player.getName().toLowerCase(Locale.ROOT);
}
public static boolean onlineMode() {
return Bukkit.getServer().getOnlineMode();
}
public static void warnIfRisky(org.bukkit.plugin.Plugin plugin) {
if (!onlineMode()) {
plugin.getLogger().warning(
"Server is in offline mode: UUIDs are derived from names. "
+ "Data is indexed on offline_key, not on the UUID.");
}
}
}A startup warning when the server is in offline mode costs three lines and saves hours of guesswork for whoever inherits your plugin.
The schema that survives a rename
One internal key, two identity columns
The fix is not to pick between the UUID and the name, it is to attach neither of them to your data tables. You create an internal identifier, a plain auto-incrementing integer, and every data table points at that. Identity columns live in one place only, the players table. The day an identity changes you write a single-row UPDATE, and the entire history follows without moving.
CREATE TABLE IF NOT EXISTS players (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mojang_uuid TEXT UNIQUE,
offline_key TEXT UNIQUE,
last_name TEXT NOT NULL,
first_seen INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS balances (
player_id INTEGER PRIMARY KEY REFERENCES players(id) ON DELETE CASCADE,
coins INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_players_last_name ON players(last_name);With this schema, balances, homes, stats, and everything you add later reference players.id. Migrating a server from offline to online mode becomes a routine maintenance job instead of a data loss. That is the one structural difference between a tutorial plugin and a plugin that lasts.
Always keep last_name fresh
UPSERT as everything else.package fun.example.playerdata;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.UUID;
public final class PlayerStore {
private static final String UPSERT = """
INSERT INTO players (mojang_uuid, offline_key, last_name, first_seen, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(offline_key) DO UPDATE SET
mojang_uuid = excluded.mojang_uuid,
last_name = excluded.last_name,
updated_at = excluded.updated_at
""";
private final PlayerDataPlugin plugin;
private final Connection connection;
public PlayerStore(PlayerDataPlugin plugin, Connection connection) {
this.plugin = plugin;
this.connection = connection;
}
public int loadCoins(UUID uuid, String offlineKey) {
String sql = """
SELECT b.coins FROM balances b
JOIN players p ON p.id = b.player_id
WHERE p.offline_key = ? OR p.mojang_uuid = ?
""";
// Always a PreparedStatement: a player name is untrusted user input.
try (PreparedStatement ps = connection.prepareStatement(sql)) {
ps.setString(1, offlineKey);
ps.setString(2, uuid.toString());
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getInt(1) : 0;
}
} catch (SQLException e) {
plugin.getLogger().warning("Could not read the balance: " + e.getMessage());
return 0;
}
}
}A database call never touches the main thread
The rule with no exceptions
The main thread runs the game loop. While it is busy nothing moves: not mobs, not redstone, not network packets. A query issued from an event listener therefore pauses the entire server until the answer comes back. On local SQLite that goes unnoticed during testing, and becomes very visible on a full evening. On a remote MySQL, every round trip is a TPS freeze you can watch. Database access must never block the main thread, full stop.
The pattern that works has three rules. Read asynchronously on join, keep the value hot in memory for the session, write asynchronously on quit. During gameplay no command and no event talks to the database: they talk to the cache.
package fun.example.playerdata;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
public final class PlayerDataListener implements Listener {
private final PlayerDataPlugin plugin;
private final PlayerStore store;
private final Map<UUID, Integer> cache = new ConcurrentHashMap<>();
public PlayerDataListener(PlayerDataPlugin plugin, PlayerStore store) {
this.plugin = plugin;
this.store = store;
}
@EventHandler(priority = EventPriority.MONITOR)
public void onJoin(PlayerJoinEvent event) {
Player player = event.getPlayer();
UUID uuid = player.getUniqueId();
String key = Identity.offlineKey(player);
// The read leaves the game thread. The player is already playing, nothing waits.
Bukkit.getAsyncScheduler().runNow(plugin, task -> {
int coins = store.loadCoins(uuid, key);
cache.put(uuid, coins);
// Hop back to the thread that owns the entity. Works on Folia too.
player.getScheduler().run(plugin, back -> {
if (player.isOnline()) {
player.sendMessage(Component.text(
"Balance loaded: " + coins, NamedTextColor.YELLOW));
}
}, null);
});
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
UUID uuid = event.getPlayer().getUniqueId();
Integer coins = cache.remove(uuid);
if (coins == null) {
return;
}
String key = Identity.offlineKey(event.getPlayer());
Bukkit.getAsyncScheduler().runNow(plugin, task -> store.saveCoins(uuid, key, coins));
}
/** Instant read from memory: no database access on the game thread. */
public int coins(UUID uuid) {
return cache.getOrDefault(uuid, 0);
}
}Two details deserve attention. Bukkit.getAsyncScheduler() is Paper's async scheduler and it works on a classic server as well as on Folia, unlike the old runTaskAsynchronously from the tutorials. And the hop back to the player goes through player.getScheduler(), not the main thread: on Folia, the thread that owns a player is not necessarily the one you assume.
Three classic mistakes inside this pattern
onDisable while async writes are still in flight, which silently drops the last saves. That is what flushBlocking prevents. Calling the Bukkit API from the async thread, which produces random exceptions nobody can reproduce. And concatenating a player name into a query instead of using a PreparedStatement: in offline mode a name is arbitrary user input, so it is a live SQL injection surface.When MySQL earns its place
The only real technical boundary
MySQL is not the grown-up version of SQLite. It is a tool that solves one specific problem: several processes that must read and write the same data. A SQLite file is built for one process. Pointing two Minecraft servers at the same file, especially over a network share, is corruption waiting to happen, and the locking limits are documented by SQLite itself.
| SQLite | MySQL | |
|---|---|---|
| Driver | Already in Paper | Declare it in libraries: |
| Setup | None, a single file | A server to host |
| One server | The right call | Needless complexity |
| Several servers | Guaranteed corruption | Its reason to exist |
| Backup | Copy the file | A dump to schedule |
| Failure points | No new one | Network plus database |
If you do move to MySQL you need a connection pool, so you need HikariCP, which is not shipped. That is exactly what the libraries: block is for. Declare the MySQL driver too rather than assuming it is present.
name: PlayerData
version: 1.0.0
main: fun.example.playerdata.PlayerDataPlugin
api-version: '1.21'
folia-supported: true
libraries:
- com.zaxxer:HikariCP:5.1.0
- com.mysql:mysql-connector-j:8.4.0package fun.example.playerdata;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
public final class Pool {
private Pool() {
}
public static HikariDataSource create(String host, int port, String database,
String user, String password) {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://" + host + ":" + port + "/" + database);
config.setUsername(user);
config.setPassword(password);
// A Minecraft server has one game thread. A huge pool will not make it faster,
// it will only make connection leaks harder to spot.
config.setMaximumPoolSize(6);
config.setMinimumIdle(2);
config.setConnectionTimeout(5000);
config.setPoolName("PlayerData");
config.addDataSourceProperty("cachePrepStmts", "true");
return new HikariDataSource(config);
}
}Vault is still the right door for economy data
Folia and regions
Multithreading changes the rules
Folia runs world regions across several threads. A plugin written on the assumption of a single main thread does not slow down, it crashes the server. The good news is that the pattern above is already compatible: the database work is async and the hop back goes through the scheduler of the entity involved. For anything tied to a block or a position rather than an entity, use the RegionScheduler, never the global scheduler. The reference documentation is on docs.papermc.io.
Declaring folia-supported: true in plugin.yml without doing that work is worse than declaring nothing: the server believes you and loads the plugin.
What actually breaks
The limits you will run into
Here is the list of things that fail in production and that no tutorial mentions.
- Code copied from a 1.21 tutorial no longer compiles. Paper 26.x ships Adventure 5, which removed APIs that were previously only deprecated. Anything touching messages and text shown to players is hit first.
- Deobfuscation changed the landscape. Minecraft has been deobfuscated since 26.1 and Paper's remapper is gone. The old recipes for reaching into server internals, still copied everywhere, no longer describe reality.
- SQLITE_BUSY under load. Two threads from your own plugin writing at the same time is enough. WAL mode plus a
busy_timeouthandles most of it, but you have to set them explicitly. - The /reload command. It leaves connections open and tasks orphaned. On a plugin that owns a database it produces data corruption that is very hard to trace back to its cause. Restart the server instead.
- A cache that is never cleared. A
HashMapfilled on join but not cleaned on quit is a slow memory leak. It shows up after days of uptime, never in testing. - No backup plan. Copying a SQLite file while the server is writing to it gives you an unusable backup. Copy it with the server stopped, or use SQLite's online backup mechanism.
The call I would make
My opinion, without pointless hedging
One Minecraft server, whatever the player count: SQLite. The driver is already there, there is nothing to host, nothing to monitor, the backup is a cp, and the only extra failure point is a file on your disk. I have never seen a single server hit a genuine SQLite limit that did not actually come from a query on the main thread or a missing index.
SQLite, what you gain
Driver shipped by Paper
No service to host
Backup by file copy
No network latency
SQLite, what you give up
One writing process only
No sharing between servers
Foreign keys off by default
Locking you configure yourself
From the second server that must see the same data, a lobby and a survival behind a proxy for instance, MySQL becomes the right tool and the only sane one. Not before. And if you are hesitating because "there might be several later", write your data access behind a small interface: switching then costs one class, not a rewrite.
A plugin with its database, without writing the plumbing
Describe what your plugin needs to remember. Minax writes the Java, handles persistence and hands you a compiled .jar you can test online.
Create my pluginConclusion
Three decisions separate a plugin that holds up from one that loses data. Pick SQLite while you have a single server, because the driver is already in Paper. Never attach your data tables to a player UUID, because on 75.1% of servers that UUID is nothing but a digest of a name. And never let a query touch the main thread. Everything else, indexes, migrations, connection pools, follows from those three choices. For what comes next, our troubleshooting guide covers the most common startup errors.


