luaguides

Saving Player Data with DataStores

When a Roblox server shuts down, everything in memory disappears. Player currencies, inventory, and progress vanish unless you explicitly saved them somewhere. That’s what DataStoreService is for — it lets you store player data in Roblox’s cloud so it survives server restarts, teleports, and clean disconnects.

This tutorial shows you how to load data when a player joins, save it when they leave, and handle the tricky parts like auto-saving, session locking, and request budgets.

Setting Up the DataStore

Start by getting a reference to the DataStore and choosing a name that makes sense:

local DataStoreService = game:GetService("DataStoreService")
local PlayerData = DataStoreService:GetDataStore("PlayerData_v1")

Include a version suffix (_v1, _v2) in your DataStore name. When you need to change your data schema later, you can migrate from _v1 to _v2 without losing existing players’ data.

Loading Data on PlayerJoin

Wrap every DataStore call in pcall. DataStore requests fail constantly — network hiccups, budget limits, rate throttling. Without pcall, a single failure crashes your entire script.

local function loadPlayerData(player)
    local success, data = pcall(function()
        return PlayerData:GetAsync(player.UserId)
    end)

    if success then
        -- GetAsync returns nil for new players
        return data or {
            Coins = 0,
            Level = 1,
            Inventory = {}
        }
    else
        warn("Failed to load player data:", data)
        -- Return defaults so the player can still play
        return { Coins = 0, Level = 1, Inventory = {} }
    end
end

game.Players.PlayerAdded:Connect(function(player)
    local data = loadPlayerData(player)
    -- Store data as attributes for easy access elsewhere
    player:SetAttribute("Coins", data.Coins)
    player:SetAttribute("Level", data.Level)
    player:SetAttribute("Inventory", data.Inventory)
end)

For new players, GetAsync returns nil. Always provide a default table so your code doesn’t break on first login.

Saving Data on PlayerLeave

The PlayerRemoving event fires when a player leaves the game. This is your chance to persist their data:

local function savePlayerData(player)
    local data = {
        Coins = player:GetAttribute("Coins"),
        Level = player:GetAttribute("Level"),
        Inventory = player:GetAttribute("Inventory")
    }

    local success, err = pcall(function()
        PlayerData:SetAsync(player.UserId, data)
    end)

    if not success then
        warn("Failed to save player data:", err)
    end
end

game.Players.PlayerRemoving:Connect(function(player)
    savePlayerData(player)
end)

Updating Data Atomically with UpdateAsync

SetAsync overwrites a key entirely. This is fine when you’re saving a complete data table, but if you need to modify just one field — adding 100 coins to whatever the player currently has — use UpdateAsync instead:

local function addCoins(player, amount)
    local success, err = pcall(function()
        PlayerData:UpdateAsync(player.UserId, function(currentData)
            local data = currentData or { Coins = 0, Level = 1, Inventory = {} }
            data.Coins = data.Coins + amount
            return data
        end)
    end)

    if not success then
        warn("Failed to add coins:", err)
    else
        -- Update the local attribute too
        local current = player:GetAttribute("Coins") or 0
        player:SetAttribute("Coins", current + amount)
    end
end

UpdateAsync reads the current value, passes it to your callback, and writes whatever your callback returns — all in a single atomic operation. This prevents a race condition where two servers read the same values, modify them independently, and one silently overwrites the other.

Auto-Saving and Server Shutdown

PlayerRemoving doesn’t fire reliably when a server crashes or is shut down by Roblox. You need two things: a regular auto-save loop and a shutdown handler.

Auto-save loop

game.Players.PlayerAdded:Connect(function(player)
    local data = loadPlayerData(player)
    player:SetAttribute("Coins", data.Coins)
    player:SetAttribute("Level", data.Level)
    player:SetAttribute("Inventory", data.Inventory)

    -- Auto-save every 60 seconds
    task.spawn(function()
        while player and player.Parent do
            task.wait(60)
            savePlayerData(player)
        end
    end)
end)

Saving when the server closes

game:BindToClose() runs when Roblox shuts down the server:

game:BindToClose(function()
    -- Give saves time to complete (up to 30 seconds before Roblox force-kills us)
    for _, player in ipairs(game.Players:GetPlayers()) do
        savePlayerData(player)
    end
    task.wait(2)
end)

Session Locking and ProfileService

If your game has multiple servers, two servers can try to load the same player’s data simultaneously — for example, when a player teleports between places. Without session locking, both servers read the same values, modify them independently, and whichever saves last wins, silently losing the other server’s changes.

Roblox provides atomic session locking through UpdateAsync by writing a lock token when a server claims a profile and checking that token before saving. Most Roblox developers use ProfileService, a community module that handles session locking, auto-saving, data migration, and load ordering for you.

Install ProfileService as a ModuleScript in ReplicatedStorage, then use it like this:

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ProfileService = require(ReplicatedStorage.ProfileService)

local DataStore = ProfileService.GetDataStore("PlayerData_v1")

local function getProfile(player)
    local profile = DataStore:LoadProfileAsync("Player_" .. player.UserId)
    if profile then
        profile:AddUserId(player.UserId)
        profile:Reconcile()  -- Merges missing keys from default data
        profile:ListenToRelease(function()
            player:Destroy()
        end)

        if player:IsDescendantOf(game.Players) then
            player:SetAttribute("Coins", profile.Data.Coins)
            player:SetAttribute("Level", profile.Data.Level)
        end
        return profile
    else
        player:Kick("Failed to load your data. Please rejoin.")
    end
end

ProfileService handles auto-saving on a timer, releases locks when players leave, and gives you a clean profile.Data table to read and write.

Request Budgets

DataStoreService enforces a request budget per game. Every call to GetAsync, SetAsync, or UpdateAsync costs one request. If you run out of budget, requests queue and eventually time out.

You can check your budget before making a request:

local budget = DataStoreService:GetRequestBudgetForRequestType(
    Enum.DataStoreRequestType.GetAsync
)
if budget > 0 then
    -- safe to make a request
end

For most games, one save per player per minute is well within budget. Batch updates rather than saving on every action if you’re tight on budget.

Common Mistakes

Skipping pcall. Every DataStore call can fail. If you don’t wrap it, one network hiccup crashes your whole script.

Using SetAsync to update individual fields. If you read with GetAsync, modify locally, then SetAsync, you’ve created a race condition. Use UpdateAsync for anything that reads-then-writes.

Relying only on PlayerRemoving. Server crashes don’t fire PlayerRemoving. Always add a BindToClose handler and a periodic auto-save.

Saving on every action. Each save costs a request. If you save every time a player gains a coin, you’ll burn through your budget fast. Save periodically or on meaningful milestones.

Forgetting that GetAsync returns nil for new players. Check for nil and provide defaults, or your code will try to index nil.

See Also