Skip to content
RustPluginGenerator

Oxide Data Files: Saving and Loading Plugin Data in Rust Plugins

Oxide data files are JSON files in oxide/data that a plugin reads and writes with Interface.Oxide.DataFileSystem.ReadObject and WriteObject. Use them for runtime state such as player stats, cooldowns and homes, and use the config file for settings the admin edits. Load in Init or OnServerInitialized, save on OnServerSave and Unload, and clear or rotate the data in OnNewSave.

7 min readUpdated Oct 26, 2026

An Oxide data file is a JSON file under oxide/data that your plugin reads and writes through Interface.Oxide.DataFileSystem. Use it for state the plugin creates while running (stats, homes, cooldowns, claimed rewards). Settings that an admin edits belong in the config file instead.

Data files versus config files

ConfigData
Locationoxide/config/<PluginName>.jsonoxide/data/<name>.json
Written byThe server ownerThe plugin
ContentsSettings, limits, prefab names, multipliersPlayer records, timestamps, counters
Loaded byLoadConfig (plugin base class)Your code, using DataFileSystem
Typical sizeA few KBA few KB to many MB
Wipe behaviorKeptOften cleared

A good test: if the admin would reasonably open the file and change a value, it is config. If the plugin changes it every few minutes, it is data. Mixing the two makes admins lose settings when you reset data on wipe, or lose data when they reset config.

On Carbon the same call stores files in carbon/data; Carbon loads Oxide plugins unchanged (see Oxide vs Carbon).

Reading and writing with DataFileSystem

The two core calls need using Oxide.Core;.

  • Interface.Oxide.DataFileSystem.ReadObject<T>(string name) reads the file and deserializes it into T.
  • Interface.Oxide.DataFileSystem.WriteObject(string name, object data) serializes data and writes the file.
  • Interface.Oxide.DataFileSystem.ExistsDatafile(string name) reports whether the file exists.

Pass the name without the .json extension. When the file does not exist, ReadObject returns a new instance of T, so the result is normally not null, but you should still guard against it.

A minimal data class

Keep the data model as plain classes with simple fields. Use a dictionary keyed by the player's ID string, since JSON object keys are strings.

C#
private StoredData data;

private class StoredData
{
    public Dictionary<string, PlayerRecord> Players = new Dictionary<string, PlayerRecord>();
}

private class PlayerRecord
{
    public string LastName = "";
    public int Kills;
    public long LastSeenUnix;
}

Loading

Load in Init so the data is ready before any hook that uses it. Both Init and Loaded run before the server-up callback, so players who connect later always find the data in place.

C#
private const string DataName = "StatsDemo/players";

private void Init()
{
    LoadData();
}

private void LoadData()
{
    try
    {
        data = Interface.Oxide.DataFileSystem.ReadObject<StoredData>(DataName);
    }
    catch (Exception ex)
    {
        PrintWarning("Data file could not be read: " + ex.Message);
        data = null;
    }
    if (data == null) data = new StoredData();
}

private void SaveData()
{
    if (data == null) return;
    Interface.Oxide.DataFileSystem.WriteObject(DataName, data);
}

File naming and subfolders

The name is a path relative to oxide/data, without extension. Rules that keep you out of trouble:

  • Use the plugin name as the first segment: StatsDemo/players creates oxide/data/StatsDemo/players.json. Subfolders are created automatically.
  • One folder per plugin keeps the data directory readable once a server has dozens of plugins.
  • Do not build names from user input. A player name or chat argument may contain characters that are invalid in file names. If you need per-player files, name them by UserIDString.
  • A plain name such as StatsDemo writes oxide/data/StatsDemo.json. That is fine for a single small file.
  • Do not share one file between plugins. Each plugin holds its own in-memory copy and the last writer wins.

Splitting data into several files makes sense when parts change at very different rates, for example a large history file that is written once per wipe and a small file of active cooldowns that changes constantly.

When to save

Writing the file is a synchronous disk operation on the main thread, so saving on every change is the most common cause of avoidable hitches. Use three triggers instead.

1. OnServerSave

OnServerSave fires when the server saves the world (the automatic save interval and the server.save command). Saving your data here keeps it consistent with the map, which matters when the server crashes and rolls back to the last world save.

C#
private void OnServerSave()
{
    if (dirty) SaveData();
}

2. Unload

Unload runs on plugin reload, unload and server shutdown. Always save here, because a reload after editing the .cs file is a normal event on a live server.

C#
private void Unload()
{
    SaveData();
}

If your Unload code can throw (for example while destroying UI), put SaveData() first so an exception cannot skip it.

3. A dirty flag

Set a boolean whenever the data changes and only write when it is set. This avoids rewriting an unchanged multi-megabyte file on every world save.

C#
private bool dirty;

private void AddKill(BasePlayer player)
{
    if (player == null) return;
    PlayerRecord rec;
    if (!data.Players.TryGetValue(player.UserIDString, out rec))
    {
        rec = new PlayerRecord();
        data.Players[player.UserIDString] = rec;
    }
    rec.LastName = player.displayName;
    rec.Kills++;
    dirty = true;
}

private void SaveData()
{
    if (data == null) return;
    Interface.Oxide.DataFileSystem.WriteObject(DataName, data);
    dirty = false;
}

For data where losing a few minutes is unacceptable, such as paid currency, add a timer that saves every few minutes when the flag is set. Keep a reference to the Timer and call Destroy() on it in Unload. See the timers guide.

You can also save in OnPlayerDisconnected for plugins where one player's record matters a lot, but with a dirty flag it is usually unnecessary.

Performance of large files

WriteObject serializes the whole object graph, then writes the file. Cost grows with size, so a 20 MB file can freeze the server for noticeable time. If a save crosses 100 ms, Oxide logs a "took ms" warning for the hook (see the hook performance warning).

Practical measures:

  • Keep records small. Store a timestamp as a long, not a formatted string. Skip fields that hold default values.
  • Prune old records: drop players not seen for several wipes, and expire cooldowns that have passed.
  • Save only when dirty, and never inside frequent hooks such as OnEntityTakeDamage, OnPlayerInput or OnTick.
  • Split rarely changing bulk data from hot data so the hot file stays small.
  • Do not use System.IO yourself to work around this; stay within DataFileSystem and consider a smaller data model first.

More general advice is in the performance guide.

Do not store Unity or game objects

JSON serialization of Unity and Rust objects either produces very large output, loops, or cannot be read back. Store plain values and rebuild objects on load.

Do not storeStore instead
BasePlayerUserIDString (string) or the ulong ID
Vector3A small class with float X, Y, Z
ItemShortname (string), amount, skin ID, condition
BaseEntitynet.ID.Value (ulong), re-checked on load
TimerA unix timestamp when the effect ends
C#
private class SavedPosition
{
    public float X, Y, Z;

    public SavedPosition() { }
    public SavedPosition(Vector3 v) { X = v.x; Y = v.y; Z = v.z; }
    public Vector3 ToVector3() => new Vector3(X, Y, Z);
}

This needs using UnityEngine;. Remember that entity net IDs do not survive a wipe, and may not survive a restart for entities that are not saved; always check that the entity still exists before using a stored ID. Items from rust-items-and-inventories are rebuilt with ItemManager.CreateByName, which returns null for an unknown shortname.

If you see failed to save problems from illegal objects, a stored field is almost certainly a game type; see failed to save config: illegal objects, which has the same cause.

Wipe handling with OnNewSave

OnNewSave is called when the server detects a new map save, that is, a wipe. It is the place to reset data that should not carry over: claimed kits, per-wipe stats, stored base positions, cooldowns.

C#
private void OnNewSave(string filename)
{
    data = new StoredData();
    SaveData();
    Puts("Wipe detected, data reset.");
}

Puts is acceptable here because the hook fires once per wipe.

Points to consider:

  • Decide per field what survives. Lifetime stats may stay while a "claimed this wipe" flag should reset. Keep these in separate structures so a reset is simple.
  • Consider renaming the old file rather than deleting it, for example writing the old data to StatsDemo/players_prev before starting fresh, so an admin can recover from an accidental reset.
  • Entity IDs and positions stored in data are invalid after a map wipe, so always clear those.
  • A blueprint-only wipe does not change the map save, so do not assume OnNewSave runs then.

The wipe day checklist lists what to verify after a wipe, and the recipes for one per wipe kits and wipe countdown show typical usage.

Recovering from corrupt data files

A data file can be corrupted by a crash during a write, a manual edit with a missing comma, or a data class that changed shape. A bad file typically surfaces as a JSON reader exception (see JsonReaderException on a data file).

If you let that exception escape from Init, the plugin fails to load (see failed to initialize plugin). Instead:

  1. Wrap ReadObject in try/catch, as in LoadData above.
  2. On failure, log a single warning and fall back to an empty object so the plugin still runs.
  3. Do not save immediately after a failed read, or the fallback overwrites the broken file and destroys any chance of manual repair. A simple approach is a flag such as loadFailed that makes SaveData first copy the broken contents aside.

Because plugins may not use System.IO, copying the raw file is not possible from your code. A safe alternative is to refuse to write on top of a failed read until the admin acts:

C#
private bool loadFailed;

private void LoadData()
{
    try
    {
        data = Interface.Oxide.DataFileSystem.ReadObject<StoredData>(DataName);
        loadFailed = false;
    }
    catch (Exception ex)
    {
        PrintWarning("Data file is unreadable, running with empty data and NOT saving: " + ex.Message);
        loadFailed = true;
    }
    if (data == null) data = new StoredData();
}

private void SaveData()
{
    if (data == null || loadFailed) return;
    Interface.Oxide.DataFileSystem.WriteObject(DataName, data);
    dirty = false;
}

The admin can then fix or delete the file in oxide/data and run oxide.reload StatsDemo (see Oxide console commands). Adding new fields to a data class is safe: missing keys in an older file take the field's default value. Renaming or changing the type of an existing field is not, so add a new field and migrate old values in code.

Complete example

This plugin counts player kills, saves on world save and unload, resets at wipe, and shows the count with a chat command.

C#
using System;
using System.Collections.Generic;
using Oxide.Core;

namespace Oxide.Plugins
{
    [Info("Stats Demo", "RustPluginGenerator", "1.0.0")]
    [Description("Counts player kills and stores them in a data file")]
    public class StatsDemo : RustPlugin
    {
        private const string PermUse = "statsdemo.use";
        private const string DataName = "StatsDemo/players";

        private StoredData data;
        private bool dirty;
        private bool loadFailed;

        private class StoredData
        {
            public Dictionary<string, PlayerRecord> Players = new Dictionary<string, PlayerRecord>();
        }

        private class PlayerRecord
        {
            public string LastName = "";
            public int Kills;
        }

        protected override void LoadDefaultMessages()
        {
            lang.RegisterMessages(new Dictionary<string, string>
            {
                ["NoPermission"] = "You do not have permission to use this command.",
                ["Kills"] = "You have {0} kills this wipe."
            }, this);
        }

        private string Msg(string key, string userId = null, params object[] args) =>
            string.Format(lang.GetMessage(key, this, userId), args);

        private void Init()
        {
            permission.RegisterPermission(PermUse, this);
            LoadData();
        }

        private void Unload()
        {
            SaveData();
        }

        private void OnServerSave()
        {
            if (dirty) SaveData();
        }

        private void OnNewSave(string filename)
        {
            data = new StoredData();
            loadFailed = false;
            dirty = true;
            SaveData();
        }

        private void OnPlayerDeath(BasePlayer victim, HitInfo info)
        {
            if (victim == null || info == null) return;
            var attacker = info.InitiatorPlayer;
            if (attacker == null || attacker == victim || attacker.IsNpc) return;

            PlayerRecord rec;
            if (!data.Players.TryGetValue(attacker.UserIDString, out rec))
            {
                rec = new PlayerRecord();
                data.Players[attacker.UserIDString] = rec;
            }
            rec.LastName = attacker.displayName;
            rec.Kills++;
            dirty = true;
        }

        [ChatCommand("mykills")]
        private void CmdMyKills(BasePlayer player, string command, string[] args)
        {
            if (player == null) return;
            if (!permission.UserHasPermission(player.UserIDString, PermUse))
            {
                player.ChatMessage(Msg("NoPermission", player.UserIDString));
                return;
            }
            PlayerRecord rec;
            int kills = data.Players.TryGetValue(player.UserIDString, out rec) ? rec.Kills : 0;
            player.ChatMessage(Msg("Kills", player.UserIDString, kills));
        }

        private void LoadData()
        {
            try
            {
                data = Interface.Oxide.DataFileSystem.ReadObject<StoredData>(DataName);
                loadFailed = false;
            }
            catch (Exception ex)
            {
                PrintWarning("Data file is unreadable, running with empty data and not saving: " + ex.Message);
                loadFailed = true;
            }
            if (data == null) data = new StoredData();
        }

        private void SaveData()
        {
            if (data == null || loadFailed) return;
            Interface.Oxide.DataFileSystem.WriteObject(DataName, data);
            dirty = false;
        }
    }
}

Grant the permission with oxide.grant group default statsdemo.use. For finished versions of this idea, see the player stats recipe and the playtime tracker. To generate a plugin that already includes data handling, use the generator.

Checklist

  • Data in oxide/data, settings in oxide/config.
  • Plain classes with simple types; no BasePlayer, Vector3, Item or entities.
  • Load in Init with try/catch and a safe fallback.
  • Save in OnServerSave (if dirty) and Unload.
  • Reset per-wipe fields in OnNewSave.
  • Never write on every event, and prune old records.

FAQ

Do I need to write the .json extension in ReadObject and WriteObject?

No. Pass the name without the extension. WriteObject("MyPlugin/players", data) creates oxide/data/MyPlugin/players.json. Subfolders are created as needed. The same relative name is used by ExistsDatafile, which lets you check whether a file is there before reading it.

Why did my data file come back empty after a restart?

Usually the plugin never wrote the file. Either it saved only on a manual command, or Unload threw an exception before the save ran. Also check that you did not overwrite the file with an empty default object after a failed read. Save in OnServerSave and Unload, and log a warning when a read fails.

Can I store Vector3 or BasePlayer in a data file?

Avoid it. Unity types serialize badly or produce huge output, and a BasePlayer or entity reference cannot be restored after a restart. Store plain values instead: three floats for a position, the player's UserIDString or ulong ID, and entity net IDs only if you re-validate them on load.

Is it safe to share one data file between two plugins?

It is not a good idea. Each plugin keeps its own in-memory copy and overwrites the file on save, so one plugin's changes erase the other's. Give each plugin its own file or subfolder, and let plugins exchange data through hook calls instead, as described in the calling other plugins guide.

Does Carbon use the same data folder?

Carbon stores data in carbon/data instead of oxide/data. Plugins that use Interface.Oxide.DataFileSystem run unchanged on Carbon, which maps the call to its own folder. If you move a server between frameworks, copy the data files across by hand.

Skip the C#: describe your plugin

Open generator