Skip to content

· Development

Giving your plugin a database: SQLite or MySQL

A SQLite database schema wired to a Paper Minecraft server

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 fleetWhat it means for your database
1.21.1136.4%Your default compatibility target
26.1.214.9%The sane production target on calendar builds
26.27.4%No stable Paper build, avoid in production
1.21.47.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.

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.

Java
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

Bundling 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

The ticket will never say "UUID problem". It will say "I lost my money after changing my name", or "since the update everyone started over from scratch". Same cause. And because the old rows are still sitting in the table, restoring a backup fixes nothing.
Java
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.

SQL
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

The current name is not just a display convenience, it is your only way of finding a player when an admin says "give Kevin his money back". Update it on every join, in the same UPSERT as everything else.
Java
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.

Java
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

Closing the connection in 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.

SQLiteMySQL
DriverAlready in PaperDeclare it in libraries:
SetupNone, a single fileA server to host
One serverThe right callNeedless complexity
Several serversGuaranteed corruptionIts reason to exist
BackupCopy the fileA dump to schedule
Failure pointsNo new oneNetwork 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.

plugin.yml
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.0
Java
package 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

If your data is a money balance, look at Vault first, the interoperability standard of the ecosystem. A plugin that exposes its economy through Vault works with everything else on the server. Reinventing your own isolated currency table means never integrating with anything.

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_timeout handles 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 HashMap filled 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 plugin

Conclusion

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.

Related articles