What Is a DataStore and Why Do You Need One?
When a player leaves your Roblox game, everything in memory is gone. Their coins, their level, their inventory — all wiped. The server that was running the game shuts down, and nothing survives. If you want player progress to carry over between sessions, you need to save it somewhere permanent. That somewhere is a DataStore.
A DataStore is a key-value storage system provided by Roblox. You give it a key (usually the player's UserId) and a value (usually a table of all their stats), and Roblox saves it to their cloud servers. The next time that player joins, you read the data back and restore their progress. Without a DataStore, your game resets every single time a player rejoins.
DataStoreService Basics
All DataStore operations start with DataStoreService. You get a reference to a named store, then use methods like GetAsync, SetAsync, and UpdateAsync to read and write data. Here is a basic example that loads a player's data when they join and gives them a default table if they are new.
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")
local playerStore = DataStoreService:GetDataStore("PlayerData")
Players.PlayerAdded:Connect(function(player)
local key = "Player_" .. player.UserId
local success, data = pcall(function()
return playerStore:GetAsync(key)
end)
if success then
if data then
-- Returning player, restore their data
player:SetAttribute("Coins", data.Coins)
player:SetAttribute("Level", data.Level)
else
-- New player, set defaults
player:SetAttribute("Coins", 0)
player:SetAttribute("Level", 1)
end
else
warn("Failed to load data for " .. player.Name .. ": " .. tostring(data))
-- Give defaults so they can still play
player:SetAttribute("Coins", 0)
player:SetAttribute("Level", 1)
end
end)
GetAsync reads data. SetAsync overwrites whatever is stored at that key with a new value. UpdateAsync is safer for writes because it reads the current value first, lets you transform it, and then saves — all as one atomic operation. You should prefer UpdateAsync over SetAsync in most cases because it avoids race conditions where two servers might overwrite each other's changes.
The Right Way to Save Player Data
You need to save data in two places: when a player leaves, and when the server shuts down. If you only save on PlayerRemoving, you will lose data every time Roblox force-closes a server (which happens regularly during updates, crashes, or low-population shutdowns). The fix is to also use game:BindToClose.
local function savePlayerData(player)
local key = "Player_" .. player.UserId
local data = {
Coins = player:GetAttribute("Coins") or 0,
Level = player:GetAttribute("Level") or 1,
}
local success, err = pcall(function()
playerStore:UpdateAsync(key, function(oldData)
return data
end)
end)
if not success then
warn("Failed to save data for " .. player.Name .. ": " .. tostring(err))
end
end
Players.PlayerRemoving:Connect(savePlayerData)
game:BindToClose(function()
for _, player in Players:GetPlayers() do
task.spawn(savePlayerData, player)
end
task.wait(3) -- Give requests time to complete
end)
Important: BindToClose gives you about 30 seconds before the server is killed. Use task.spawn to save all players in parallel rather than one at a time, otherwise you might run out of time in a busy server.
Rate Limits and Throttling
Roblox does not let you make unlimited DataStore requests. The budget is roughly 60 + 10 per player per minute for each request type (reads, writes, etc.). If you blow through this budget, your requests start getting queued and delayed, or they fail outright.
What this means in practice:
- Do not auto-save every 10 seconds for every player. Save when they leave, and maybe once every 5 minutes as a safety net.
- Do not make a separate read/write for every stat. Bundle everything into one table per player.
- If you have 50 players in a server, your budget is 60 + 500 = 560 requests per minute. That sounds like a lot, but it goes fast if you are careless.
Data Structure Design
The single biggest design decision you will make is how you structure your keys. The correct approach is one key per player, one table per key. Do not create separate DataStore keys for coins, level, inventory, settings, and so on. Every extra key is another request against your budget.
-- BAD: separate keys per stat
playerStore:SetAsync("Coins_" .. userId, 500)
playerStore:SetAsync("Level_" .. userId, 12)
playerStore:SetAsync("Inventory_" .. userId, {...})
-- That's 3 requests just to save one player
-- GOOD: one key, one table
playerStore:UpdateAsync("Player_" .. userId, function()
return {
Coins = 500,
Level = 12,
Inventory = {...},
Settings = { MusicOn = true, Volume = 0.8 },
}
end)
-- That's 1 request for everything
Keep your data table flat where possible. Deeply nested tables are harder to migrate later, and Roblox serializes everything as JSON under the hood, so there is a 4 MB limit per key. For most games you will never hit this, but if you store every action a player has ever taken, you might.
Session Locking
Here is a scenario that will corrupt your data if you are not careful. A player is in Server A. They join Server B (maybe through a teleport, maybe by opening a new Roblox window). Now both servers think they own that player's data. Server A is still auto-saving. Server B loads the data and starts making changes. Server A saves, overwriting Server B's changes. Server B saves, overwriting Server A's changes. The result is lost progress and angry players.
This is called the session locking problem. The solution is to mark the data as "in use" by a specific server, and refuse to load it on another server until the first one releases it. Raw DataStoreService does not do this for you. You have to build it yourself, which is error-prone and complicated. This is one of the main reasons the community created wrapper libraries.
ProfileService and ProfileStore
ProfileService (and its successor ProfileStore) are open-source modules built by loleris that solve the hard problems of DataStore management. They handle session locking, automatic retries, data reconciliation, and safe saving. Instead of writing raw GetAsync/SetAsync calls, you load a "profile" for each player, and the library manages everything behind the scenes.
Most serious Roblox games use ProfileService or ProfileStore rather than raw DataStoreService. The benefits are significant:
- Session locking is built in. If a player's data is active on another server, the library waits and steals the lock safely.
- Automatic retries on failed requests.
- Data reconciliation — when you add new fields to your data template, existing players automatically get the new defaults merged in.
- No accidental overwrites between servers.
Recommendation: If you are starting a new project today, use ProfileStore. It is the latest version from the same author and has a cleaner API. ProfileService still works fine but is no longer actively updated.
OrderedDataStore
A regular DataStore stores any value (tables, strings, numbers) but you cannot query it in sorted order. An OrderedDataStore only stores integer values, but it lets you call GetSortedAsync to retrieve the top entries — perfect for leaderboards.
local OrderedStore = DataStoreService:GetOrderedDataStore("WinsLeaderboard")
-- Save a player's win count
OrderedStore:SetAsync("Player_" .. userId, totalWins)
-- Get top 10 players
local pages = OrderedStore:GetSortedAsync(false, 10)
local topPage = pages:GetCurrentPage()
for rank, entry in topPage do
print(rank .. ". " .. entry.key .. " — " .. entry.value .. " wins")
end
A common pattern is to use a regular DataStore for all player data and a separate OrderedDataStore that you update periodically just for leaderboard display. Do not try to use OrderedDataStore as your primary data storage — it only supports integers.
Handling Data Loss
DataStore requests can fail. The server might be under heavy load, Roblox's backend might have an outage, or you might have hit your rate limit. If you do not handle failures, your game will error and players will lose data.
The first rule is to always wrap DataStore calls in pcall. Never let a DataStore error crash your script. The second rule is to implement retry logic for important operations like saving.
local MAX_RETRIES = 3
local function safeUpdate(store, key, transformFunc)
for attempt = 1, MAX_RETRIES do
local success, err = pcall(function()
store:UpdateAsync(key, transformFunc)
end)
if success then
return true
end
warn("DataStore attempt " .. attempt .. " failed: " .. tostring(err))
if attempt < MAX_RETRIES then
task.wait(2 ^ attempt) -- Exponential backoff: 2s, 4s, 8s
end
end
return false
end
You should also always provide fallback default values when a load fails. Let the player play with fresh defaults rather than kicking them. You can flag their session so you do not accidentally overwrite their real data with the defaults when they leave.
Migrating Your Data Schema
Your game will evolve. You will add new systems, rename stats, or restructure your data table. The problem is that existing players already have saved data in the old format. You need a way to update their data without breaking it.
The simplest approach is to include a version number in your data table. When you load data, check the version and run migration functions to bring it up to date.
For example, if version 1 stored coins as Coins and version 2 renames it to Gold and adds a new Gems field, your migration function converts the old format to the new one on load. The player never notices. You save the updated data back with the new version number so the migration only runs once.
If you use ProfileService or ProfileStore, basic migrations are handled automatically through data reconciliation — new fields get default values merged in. But for more complex changes like renaming or restructuring fields, you still need manual migration logic.
Common Mistakes to Avoid
After helping hundreds of developers debug their data systems, these are the mistakes that come up over and over:
- Not using pcall. A single failed DataStore request will break your entire script if it is not wrapped in pcall. Every DataStore call must be protected.
- Saving too often. Auto-saving every few seconds burns through your rate limit budget and causes throttling. Save on PlayerRemoving, on BindToClose, and maybe once every 5 minutes as a backup.
- Forgetting BindToClose. If the server shuts down and you only save on PlayerRemoving, the event might not fire for all players. BindToClose is your safety net.
- Using SetAsync instead of UpdateAsync. SetAsync blindly overwrites. UpdateAsync reads first, then writes. Use UpdateAsync to avoid race conditions.
- Separate keys for each stat. Every key is a separate request. Bundle everything into one table per player.
- Not handling the "loaded with defaults" edge case. If a load fails and you give the player defaults, make sure you do not save those defaults over their real data when they leave.
- Ignoring session locking. If your game uses teleportation or players can be in multiple servers, you need session locking or you will get data corruption. Use ProfileService/ProfileStore.
Bottom line: For hobby projects and jams, raw DataStoreService with pcall and good save logic is fine. For any game you plan to publish and maintain, use ProfileStore. It solves the hard problems so you can focus on building your game.