Databases

Back a plugin with SQLite - or a networked database - without blocking the server's main thread.

Once you outgrow the config file and flat files - you need queries, indexes, or concurrent writes - reach for a database. A file-backed SQLite database lives in the data folder just like any other file, so it stays self-contained per plugin with nothing to install on the host.

The catch is that database calls block, and blocking the server's main thread freezes every player. So don't query synchronously. Run the work on the background event loop from endstone.asyncio with an async driver like aiosqlite, then hop back to the main thread with the scheduler before you touch the game.

First, declare aiosqlite as a dependency so it's installed alongside your plugin:

pyproject.toml
[project]
dependencies = ["aiosqlite>=0.20"]

This plugin keeps a per-player visit count in data/stats.db and greets each player with their tally on join:

src/endstone_my_plugin/my_plugin.py
import aiosqlite
from endstone.asyncio import submit
from endstone.event import event_handler, PlayerJoinEvent
from endstone.plugin import Plugin

class MyPlugin(Plugin):
    api_version = "0.11"

    def on_enable(self) -> None:
        self.db_path = self.data_folder / "stats.db"
        submit(self._init_db()).result()  # wait for the schema during startup
        self.register_events(self)

    async def _init_db(self) -> None:
        async with aiosqlite.connect(self.db_path) as db:
            await db.execute(
                "CREATE TABLE IF NOT EXISTS visits ("
                "  xuid  TEXT PRIMARY KEY,"
                "  name  TEXT NOT NULL,"
                "  count INTEGER NOT NULL DEFAULT 0)"
            )
            await db.commit()

    @event_handler
    def on_player_join(self, event: PlayerJoinEvent) -> None:
        player = event.player

        def greet(future) -> None:
            visits = future.result()
            # this callback runs on the background thread - hop to the main thread
            self.server.scheduler.run_task(self, lambda: self.welcome(player, visits))

        submit(self._record_visit(player.xuid, player.name)).add_done_callback(greet)

    async def _record_visit(self, xuid: str, name: str) -> int:
        async with aiosqlite.connect(self.db_path) as db:
            await db.execute(
                "INSERT INTO visits (xuid, name, count) VALUES (?, ?, 1) "
                "ON CONFLICT(xuid) DO UPDATE SET count = count + 1, name = excluded.name",
                (xuid, name),
            )
            await db.commit()
            async with db.execute("SELECT count FROM visits WHERE xuid = ?", (xuid,)) as cursor:
                (count,) = await cursor.fetchone()
                return count

    def welcome(self, player, visits) -> None:
        # the player may have left while the query ran, so check before messaging
        if player.is_valid:
            player.send_message(f"Welcome back! Visit #{visits}.")

Here's what each piece does:

  • on_enable runs once at startup: it builds the database path inside the data folder, creates the table, then registers the join listener.
  • _init_db creates the visits table the first time. CREATE TABLE IF NOT EXISTS is safe to run on every startup - it does nothing once the table exists.
  • on_player_join fires whenever a player joins; it starts the database write and arranges to greet the player once it finishes.
  • _record_visit holds the SQL. The INSERT ... ON CONFLICT ... DO UPDATE (an upsert) adds a row for a first-time player or bumps count for a returning one, then reads the new total back. The ? placeholders are filled from the (xuid, name) tuple - always pass values this way instead of putting them into the string, so a player's name can't break (or hijack) the query.
  • welcome runs back on the main thread and sends the message, but only if the player is still online.

The async def methods are coroutines, and await marks where they pause for the database without blocking the server - see Async I/O for that model.

Two threading rules carry the rest:

  • Every await on the database runs off the main thread. submit(coro) hands the coroutine to the background loop and returns a concurrent.futures.Future immediately - the server keeps ticking.
  • Results come back through the scheduler. A future's add_done_callback fires on the background thread, so anything that touches the API must be wrapped in run_task to land on the main thread - and because the player can disconnect while the query runs, welcome re-checks player.is_valid before messaging. During on_enable it's fine to .result() and wait, because the server isn't ticking players yet.

For a hot path you'd hold one long-lived connection instead of reconnecting per call, but the threading shape stays identical: query on the background loop, apply on the main thread. The same pattern swaps cleanly to a networked database - point an async driver like asyncpg at PostgreSQL instead of aiosqlite at a file.