Roblox DataStore Not Saving? 7 Causes and Fixes
Last updated:
Quick answer: Data that "saves" but disappears on rejoin is almost always a save pipeline with a hole in it: wrap every DataStore call in pcall, autosave on a timer, save on PlayerRemoving, and flush all players in game:BindToClose so no exit path loses progress.
Roblox DataStore "not saving" is almost never a broken DataStore — it is a save pipeline with a hole in it. A call errored without anyone noticing, a save ran on only one of the possible exits, or the server shut down without flushing. The seven causes below cover nearly every "my DataStore is not saving" report, and each one has a concrete fix you can paste in.
Why does data work in Studio but vanish in the live game?
In Studio you typically test alone, for a few minutes, with Enable Studio Access to API Services turned on and no server shutdowns. The live game is the opposite: dozens of players, hours-long sessions, scheduled server restarts, real network failures, and a shared request budget. A save path that only runs on a clean PlayerRemoving exit looks perfect in Studio and drops data in production the first time a player force-quits the app.
The only Studio test that proves saving works is a full round trip: join, earn something, leave (or stop the server), rejoin, and check the value came back. Until you have done that round trip, you have tested earning, not saving. And if requests are rejected outright in Studio, fix the API-access setup first — the DataStore 403 fix covers exactly that.
Cause 1: is a missing pcall hiding the failure?
GetAsync and SetAsync are web requests. Web requests fail — from throttling, outages, or a dropped connection — and when they fail outside pcall, the error kills the running thread. In a save loop that means one error, then every later save in that thread silently never runs again. That is the single most common cause of "not saving": nothing crashed loudly, the code just stopped.
local function saveData(player, data)
local success, err = pcall(function()
coinStore:SetAsync("Player_" .. player.UserId, data)
end)
if not success then
warn("Save failed for " .. player.Name .. ": " .. tostring(err))
end
return success
end
Wrap every DataStore call, always log the failure, and return whether it worked so callers can retry. For why pcall is structured around a success flag and a result, see the pcall walkthrough.
Cause 2: are you saving only when the player leaves?
PlayerRemoving fires when the server notices the player is gone — which is not the same moment they stopped playing. Crashes, killed apps, and dropped connections mean the leave-save can fire late or never fire at all, and if that one attempt fails there is no second chance. An autosave loop gives you many chances:
task.spawn(function()
while true do
task.wait(120)
for player, data in sessionData do
saveData(player, data)
end
end
end)
Two minutes is a good default: frequent enough to lose little in a crash, rare enough to stay inside the write budget (cause 5). Keep the leave-save too — autosave is a safety net, not a replacement.
Cause 3: is there no game:BindToClose?
When Roblox shuts a server down (updates, low population, scheduled restarts), scripts simply stop. PlayerRemoving does not reliably fire for everyone during shutdown, so everything since the last autosave is gone. game:BindToClose runs during shutdown and grants roughly thirty seconds to flush:
game:BindToClose(function()
for player, data in sessionData do
saveData(player, data)
end
task.wait(2) -- give queued requests time to land before the server dies
end)
Register it once in a server script. One BindToClose saves all remaining players, which is exactly why it pairs with PlayerRemoving instead of replacing it.
Cause 4: does SetAsync race another writer?
SetAsync means "write this exact value, whatever is stored right now." If anything else wrote that key since you loaded it — another server, a stale autosave snapshot racing a fresher leave-save, a retry storm from your own code — the newer value gets clobbered. The fix is UpdateAsync, which reads and writes in one atomic operation and lets you decide what should be stored:
local function saveDataAtomic(player, data)
local success, err = pcall(function()
coinStore:UpdateAsync("Player_" .. player.UserId, function(old)
-- old is the currently stored value; return what to store next
if old and old.version and old.version > data.version then
return old -- someone else wrote something newer
end
return data
end)
end)
if not success then
warn("Save failed for " .. player.Name .. ": " .. tostring(err))
end
end
UpdateAsync costs the same request budget as SetAsync and prevents lost updates, so prefer it whenever more than one code path can write the key.
Cause 5: is request budget throttling dropping your writes?
DataStores enforce two limits: roughly one write per key every six seconds, and a per-server request budget that scales with player count. Code that saves on every coin pickup exceeds both, and throttled requests are dropped — from your side that looks exactly like "not saving." Accumulate changes in memory and write on the schedule from causes 2 and 3 instead. If a big event deserves an immediate save (a purchase, a boss kill), mark the session dirty and let the autosave loop flush it within two minutes rather than writing per change.
Cause 6: are failed loads being saved back as defaults?
The sneakiest cause: your load fails (throttling, a blip), your code reads the nil as "brand-new player," fills in DEFAULT_DATA, and the next save permanently overwrites their real progress with zeros. A failed load is not the same thing as an empty profile:
local DEFAULT_DATA = { coins = 0, level = 1 }
local function loadData(player)
local success, result = pcall(function()
return coinStore:GetAsync("Player_" .. player.UserId)
end)
if not success then
return nil, "load_failed" -- NOT the same as a new player
end
if result == nil then
return table.clone(DEFAULT_DATA), "new_player"
end
return result, "loaded"
end
If the load failed, keep the player in a read-only state until a retry succeeds, or kick them with a friendly message — never save over data you never read.
What does the full safe save pipeline look like?
Put the seven fixes together and every exit path is covered:
- Load inside
PlayerAdded, wrapped inpcall, keyed byplayer.UserId. - Validate the loaded table — missing fields get defaults, old versions get migrated, and failed loads are never saved back.
- Autosave every one to two minutes in a
task.spawnloop. - Leave-save in
PlayerRemoving, writing before you clearsessionData[player]. - Shutdown-save in
game:BindToClose, iterating all remaining players and waiting briefly for requests to land. - Retry failed writes two or three times with
task.waitbackoff, then warn loudly. - Prefer
UpdateAsyncoverSetAsyncwhenever more than one writer exists.
With that pipeline, no single failure — a crash, a shutdown, a throttled request — can cost a player more than one autosave interval of progress. The full walkthrough of the happy path lives in the DataStore guide; this page is about the unhappy paths.
Common mistakes
- No
pcallaroundGetAsync/SetAsync. One transient error silently stops every future save in that thread. - Saving on every coin change. Throttling drops the writes; batch them in memory.
- Treating a failed load as a new player. That is how real progress gets overwritten with defaults.
- Saving only in
PlayerRemoving. Crashes and shutdowns skip it entirely. - Storing non-serializable values. Instances, functions, and mixed arrays make
SetAsyncerror out. - Forgetting
sessionData[player] = nil. Leaked entries keep saving for players who left long ago.
Learn Luau by doing
Reading the failure modes is half the fix — the other half is writing the load/validate/autosave/leave/shutdown pipeline yourself until every exit path is muscle memory. On Luablox you can practice Luau interactively through /theory, drill the tricky parts on /problems, and follow structured Luablox lessons that build from basics to full gameplay systems.
Frequently asked questions
Why does my DataStore save in Studio but not in the live game?
Studio sessions are short, solo, and never hit a server shutdown, so a save path that only runs on a clean PlayerRemoving exit never gets tested against crashes, throttling, or scheduled restarts. Run the full round trip — join, change a value, leave or stop the server, rejoin — and check every save call is wrapped in pcall.
Do I need both PlayerRemoving and BindToClose?
Yes. PlayerRemoving is the normal save point when a player leaves, but it does not fire reliably during server shutdown, which is exactly what BindToClose covers. BindToClose runs once at shutdown and should iterate over all remaining players, save each one, and wait briefly for the requests to finish.
How often should I autosave player data?
Every 60 to 120 seconds. That is well inside the roughly one-write-per-key-per-six-seconds throttle limit, and it caps how much progress a crash can cost. Never save per coin change — accumulate the changes in memory and flush them from the autosave loop.
Why does my data reset to defaults every rejoin?
Almost always because a failed GetAsync is being treated as a new player: the load errors, your code falls back to DEFAULT_DATA, and the next save overwrites the real data with zeros. Distinguish "load failed" from "no data yet" and never save back a profile you never successfully read.
Should I use SetAsync or UpdateAsync to save?
Use UpdateAsync whenever more than one code path can write the same key. SetAsync blindly overwrites whatever is stored, so a stale snapshot or a retry can clobber newer data; UpdateAsync reads and writes atomically and lets you merge instead of overwrite.
Understand Roblox DataStoreService patterns to save player data, implement safety checks, and handle load failures.