DataStore lets you save player progress permanently — coins, levels, inventory, stats, anything. Unlike regular variables that disappear when a player leaves, DataStore data stays on Roblox servers forever until you delete it. This guide shows you how to build a safe DataStore system from scratch.
Why DataStore Matters
Every player's data needs to survive between sessions. Without DataStore, progress resets every time someone rejoins. DataStore also protects against accidental loss: if your game crashes, the data is safe on Roblox servers, not just in your server's memory.
The key rule: always wrap DataStore calls in try-catch logic. Network delays, rate limits, and temporary outages happen. Good code plans for that.
Getting Started with DataStore
DataStore lives in a service called DataStoreService. You access it through a script in ServerScriptService (never LocalScript — LocalScripts can't use DataStore for security reasons).
Here's the simplest working example:
local DataStoreService = game:GetService("DataStoreService")
local playerDataStore = DataStoreService:GetDataStore("PlayerData")
game.Players.PlayerAdded:Connect(function(player)
local userId = player.UserId
-- Load data
local success, data = pcall(function()
return playerDataStore:GetAsync(userId)
end)
if success then
if data then
print(player.Name .. " loaded with coins: " .. data.coins)
else
print(player.Name .. " is new, no data found")
end
else
print("Failed to load data for " .. player.Name)
end
end)
game.Players.PlayerRemoving:Connect(function(player)
local userId = player.UserId
local coinsToSave = 100 -- Replace with actual player coins
-- Save data
local success, error = pcall(function()
playerDataStore:SetAsync(userId, {coins = coinsToSave})
end)
if success then
print("Saved " .. player.Name .. "'s data")
else
print("Failed to save data: " .. error)
end
end)
What's happening here:
DataStoreService:GetDataStore("PlayerData")gets or creates a named store. Every store with the same name shares data.pcall()wraps the DataStore call. If it fails, it returnsfalse, errorMessageinstead of crashing your server.GetAsync(userId)retrieves the player's saved table (or nil if they're new).SetAsync(userId, data)saves a table to that player's slot.PlayerRemovingis when to save — the player is leaving, so store their current progress.
Building a Complete Player Data System
Real games track multiple stats. Let's build a system that saves coins, level, and playtime:
local DataStoreService = game:GetService("DataStoreService")
local playerDataStore = DataStoreService:GetDataStore("PlayerStats")
-- In-memory cache: keeps current data while player is online
local playerCache = {}
local function loadPlayerData(player)
local userId = player.UserId
local defaultData = {
coins = 0,
level = 1,
playtime = 0
}
local success, data = pcall(function()
return playerDataStore:GetAsync(userId)
end)
if success then
if data then
playerCache[userId] = data
print("Loaded data for " .. player.Name)
else
playerCache[userId] = defaultData
print("New player: " .. player.Name)
end
else
-- Load failed; give default data and flag for retry
playerCache[userId] = defaultData
print("DataStore error (loading default): " .. data)
end
return playerCache[userId]
end
local function savePlayerData(player)
local userId = player.UserId
if not playerCache[userId] then
return false
end
local success, error = pcall(function()
playerDataStore:SetAsync(userId, playerCache[userId])
end)
if success then
print("Saved data for " .. player.Name)
return true
else
print("Failed to save (will retry): " .. error)
return false
end
end
game.Players.PlayerAdded:Connect(function(player)
loadPlayerData(player)
end)
game.Players.PlayerRemoving:Connect(function(player)
savePlayerData(player)
playerCache[player.UserId] = nil -- Clean up memory
end)
-- Helper functions for other scripts to use
_G.getPlayerCoins = function(player)
return playerCache[player.UserId] and playerCache[player.UserId].coins or 0
end
_G.addPlayerCoins = function(player, amount)
if playerCache[player.UserId] then
playerCache[player.UserId].coins = playerCache[player.UserId].coins + amount
end
end
Why this setup is safer:
playerCacheholds data in memory while the player plays. This is fast and lets you read/write instantly.- If DataStore fails on load, you give the player default data and they can still play (they just won't see old progress yet).
- The save function returns success/failure so you can retry if needed.
- Global functions (
_G) let other scripts easily modify and read player data safely. - On
PlayerRemoving, you save and then clear the cache to free memory.
Handling Errors and Retries
Network issues are real. A production system should retry failed saves:
local function savePlayerDataWithRetry(player, maxRetries)
maxRetries = maxRetries or 3
local userId = player.UserId
for attempt = 1, maxRetries do
local success, error = pcall(function()
playerDataStore:SetAsync(userId, playerCache[userId])
end)
if success then
print("Saved " .. player.Name .. " on attempt " .. attempt)
return true
end
if attempt < maxRetries then
print("Save attempt " .. attempt .. " failed, retrying...")
wait(2) -- Wait before retry
end
end
print("Failed to save " .. player.Name .. " after " .. maxRetries .. " attempts")
return false
end
game.Players.PlayerRemoving:Connect(function(player)
savePlayerDataWithRetry(player, 3)
playerCache[player.UserId] = nil
end)
This loops up to 3 times. If a save fails, it waits 2 seconds and tries again. If all attempts fail, you can log it for investigation.
Version Migration and Updates
As your game grows, you'll change what data you save. Old players might have data without new fields. Handle this gracefully:
local function migratePlayerData(data)
-- Set defaults for any missing fields
if not data.playtime then
data.playtime = 0
end
if not data.achievements then
data.achievements = {}
end
return data
end
local function loadPlayerData(player)
local userId = player.UserId
local defaultData = {
coins = 0,
level = 1,
playtime = 0,
achievements = {}
}
local success, data = pcall(function()
return playerDataStore:GetAsync(userId)
end)
if success then
if data then
data = migratePlayerData(data) -- Upgrade old data
playerCache[userId] = data
else
playerCache[userId] = defaultData
end
else
playerCache[userId] = defaultData
end
return playerCache[userId]
end
When you load old data, pass it through migratePlayerData(). This adds any new fields with sensible defaults. Players don't lose progress, and your code doesn't crash on unexpected data shapes.
Testing Your DataStore
Studio doesn't let you see actual DataStore contents (that's only on live servers). But you can test locally:
- Enable Studio access: Go to Home → Game Settings → Security, check "Enable Studio Access to API Services".
- Test in Studio: Run a game session and check the Output for success/failure messages.
- Simulate failures: Temporarily add code that makes pcalls fail, so you can verify your error handling works.
- Check real servers: Deploy to a test place and watch players join. Use print statements to log what's saving.
DataStore Limits and Best Practices
Rate limits: You can only save each player's data a few times per minute. Never call SetAsync in a loop or on every change. Instead, hold data in the cache and save periodically or on PlayerRemoving.
Data size: Keep total data under ~3 MB per player. Don't store entire chat logs or every frame of movement. Store only meaningful game state.
Keys: Always use UserId. Avoid storing sensitive info. DataStore is not encrypted — assume players can read their own data.
Backups: DataStore has no built-in undo. If you overwrite data by mistake, it's gone. Test thoroughly before deploying major changes.
Avoid: Don't use DataStore to hold active game state (like "Player is in lobby now"). Use memory or Attributes for that. DataStore is for persistence only.
Next Steps
Once this foundation works, consider:
- Leaderboards: Use OrderedDataStore to rank players by score.
- Cross-save: Let players use data across multiple places in your universe.
- Analytics: Log how much progress players make to balance difficulty.
- Trading/economy: Track item ownership and trade history safely.
All of these build on the safe pcall + cache pattern you just learned.