Config and persistence

Store plugin configuration and data on disk.

Endstone gives each plugin a private data folder and a built-in config file, so you can persist settings and state across restarts without managing paths yourself.

Where files live

A plugin's files live in two places - the package you ship, and the data folder the server creates for it at runtime. In your project, bundle a default config.toml inside the package, next to your code:

pyproject.toml
__init__.py
my_plugin.py
config.toml

The entry point my-plugin becomes the plugin name my_plugin (dashes turn into underscores), and the server gives it a matching data folder under plugins/:

config.toml

self.data_folder points at plugins/my_plugin/. On first run, save_default_config() copies the config.toml you packaged in endstone_my_plugin/ into that folder; anything else your plugin writes lands here too.

The data folder

Every plugin gets its own folder, exposed as self.data_folder - a pathlib.Path that's created for you. Write anything you like into it:

import json

def on_disable(self) -> None:
    scores_file = self.data_folder / "scores.json"
    scores_file.write_text(json.dumps(self.scores))

Configuration

For settings, use the built-in config.toml. self.config returns it as a dict, loading the file on first access:

def on_enable(self) -> None:
    greeting = self.config.get("greeting", "Hello!")
    self.logger.info(greeting)

Ship defaults

Bundle a config.toml with your plugin package and call save_default_config() to write it into the data folder on first run. It does nothing if the file already exists, so a player's edits are never overwritten:

def on_enable(self) -> None:
    self.save_default_config()
    greeting = self.config["greeting"]

reload_config() re-reads the file (filling in packaged defaults for any missing keys), and save_config() writes the current self.config back to disk:

self.config["greeting"] = "Welcome back!"
self.save_config()

Resource files

To ship non-config resources - a data file, a template - call save_resources(path). It copies a file packaged inside your plugin into the data folder, preserving its relative path, and skips files that already exist unless you pass replace=True:

self.save_resources("data/levels.json")

The path is relative to your package, so bundle the file under endstone_my_plugin/:

__init__.py
my_plugin.py
levels.json

It's recreated at the same relative path inside the data folder:

config.toml
levels.json

A SQL database

Once you outgrow 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.

On this page