[Tutorial] Learn Roblox DB in 20 minutes - Saving Player Data to the Database Using ProfileStore (with types!)


Disclaimer: This tutorial is being written on February 7th, 2025. As time goes on, it will become more and more likely that this tutorial becomes out of date somehow as the ProfileStore module is likely to change. Also, this tutorial does not cover every feature of ProfileStore, just a basic working implementation.

Hello, I am a software engineer with 5 years of experience in the tech industry (although I am new to Roblox and game development).

In this tutorial, I’m going to give a step by step guide to saving and retrieving player data using Roblox’s database. Specifically, we’re going to use an open source module called ProfileStore. This module does a lot of things for us. Visit the linked forum post to get more details about ProfileStore. This tutorial assumes a basic understanding of scripting. Also, be sure to enable HTTP requests in your roblox studio settings.

To add ProfileStore to your project, go to the Toolbox and search “ProfileStore” under the “Models” category. It should be the first result by Loleris.

Understanding the steps of the process

What do we want to accomplish?

1. Define our data model

We want a typed definition of our data model. This is useful because it will serve as documentation for all of the data we’re storing for our game. It will be easy for you to reference if you ever forget something. Defining a type also gives us the benefit of autocomplete, and if you have --!strict mode on your script, it will give you type checking as well.

2. Load player data when the player joins the game

When a player joins our game, we need to load their previously saved data.

3. Save player data while player is playing

While the player is in our game, we need to save their data so that it persists across play sessions.

4. Save when player leaves or the server shuts down

We need to make sure to save the player data when they leave the game. We also need to make sure to handle the scenario where the Roblox server gets terminated for some reason.

Here is each step broken down:

Define our Data Model

We want a typed definition of our model. How do we accomplish this? Well, ProfileStore makes use of something called Profiles. A Profile object is what we use to read from and write data to the database. Luckily, the ProfileStore module comes with types, and we can build off of them to define our data model.

In the ProfileStore script, if you do control + f (command + f on mac), it will bring up a text input we can use to search for any terms in the script. Type the following: export type Profile and press Enter. It should take you to the definition of Profile, which looks like this:

export type Profile<T> = {
	Data: T & JSONAcceptable,
	LastSavedData: T & JSONAcceptable,
        ...
        ...

Go ahead and remove JSONAcceptable as a type from Data and LastSavedData. Why? When it has that type, Luau’s type checker gets confused, so let’s remove it. After you remove it, the type definition should look like this:

export type Profile<T> = {
	Data: T,
	LastSavedData: T,
        ...
        ...

Q: Why is it Profile<T> instead of just Profile? Answer: <T> is a notation that basically means we can pass any type we want into Profile. When Data and LastSavedData are set to type T, we’re saying that their type is that of the passed-in type. You will see an example of this soon.

Great! Now we can define our data model. Personally, I did this in a file called PlayerDataTemplate. I named it this because one cool feature of ProfileStore is that Player data is initialized to the default values you specify in your data model. This applies to players who are joining your game for the very first time.. Other names such as DataModel, PlayerData, etc would also work.

Make sure to add --!strict to the top of your file so that type checking is applied.

Here’s a starting version of PlayerDataTemplate.lua

--!strict
local ServerStorage = game:GetService("ServerStorage")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

-- Assumes a Folder named ModuleScripts is a child of ServerStorage,
-- a Folder named Dependencies is a child of ModuleScripts, and 
-- a ModuleScript named ProfileStore is a child of Dependencies. 
local ModuleScripts = ServerStorage:WaitForChild("ModuleScripts")
local Dependencies = ModuleScripts:WaitForChild("Dependencies")
local ProfileStore = require(Dependencies:WaitForChild("ProfileStore"))

-- Defining our own type of profile by passing in our PlayerDataTemplate type
export type Profile = ProfileStore.Profile<PlayerDataTemplate>
-- Type definition for PlayerDataTemplate, we haven't defined anything yet
export type PlayerDataTemplate = {}
-- Default values of PlayerDataTemplate. Players that are brand new to your game
-- will start out with this data
local PlayerDataTemplate: PlayerDataTemplate = {}

return PlayerDataTemplate

How will we define our model? Well, it’s important to know that data within the Roblox database exist as Tables. Therefore, we will define our model as a table. In this example, I’m going to show how someone might store XP, Level, and Titles. Before we do that, I want to quickly discuss limitations.

Limitations of what can be stored

First, we should know that the only types of values we can store are table, number, string, buffer, boolean, and nil.

Finally, all of the tables we store must have either all string indices or all number indices. This means all keys of a given table must be of the same type. Here’s some code to show examples:

-- "hi" and "property" are both strings. No problem!
local goodTable1 = {
	hi = "Hello", 
	property = "Value",
}

-- 1 and 2 are both numbers. No problem!
local goodTable2 = {
	[1] = "Hello", 
	[2] = "Value",
}

-- 1 is a number but "property" is a string. Bad!
local badTable = {
	[1] = "Hello", 
	property = "Value",
}

XP and Level

This is the easiest one. XP and Level are both going to be represented as a number.

Our default template and type definition now looks like this:

export type PlayerDataTemplate = {
	XP: number, 
	Level: number,
}

-- Default values of PlayerDataTemplate. Players that are brand new to your game
-- will start out with this data
local PlayerDataTemplate: PlayerDataTemplate = {
	XP = 0,
	Level = 1,
}

Titles

For Titles, we’re going to store two pieces of information. In a real game you may want more than this, but I’m sticking with two for the sake of simplicity.

The Title type will store the name of the title and the time the player earned the title. Storing the time the player earned a title could be useful for something such as giving founding players a reward.

Let’s translate that into a type.

export type Title = {
	Name: string,
	-- Date the title was earned 
	EarnedAt: number,
}

Now our definitions look like this. For your convenience, I am pasting the full script below:

--!strict
local ServerStorage = game:GetService("ServerStorage")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

-- Assumes a Folder named ModuleScripts is a child of ServerStorage,
-- a Folder named Dependencies is a child of ModuleScripts, and 
-- a ModuleScript names ProfileStore is a child of Dependencies. 
local ModuleScripts = ServerStorage:WaitForChild("ModuleScripts")
local Dependencies = ModuleScripts:WaitForChild("Dependencies")
local ProfileStore = require(Dependencies:WaitForChild("ProfileStore"))

-- Defining our own type of profile by passing in our PlayerDataTemplate type
export type Profile = ProfileStore.Profile<PlayerDataTemplate>

export type Title = {
	Name: string,
	-- Date the title was earned 
	EarnedAt: number,
}

-- Type definition for PlayerDataTemplate
export type PlayerDataTemplate = {
	XP: number, 
	Level: number,
	OwnedTitles: { Title },
}

-- Default values of PlayerDataTemplate. Players that are brand new to your game
-- will start out with this data
local PlayerDataTemplate: PlayerDataTemplate = {
	XP = 0,
	Level = 1,
	OwnedTitles = { 
		{Name = "Noob", EarnedAt = tick()},
		{Name = "Weakling", EarnedAt = tick()}
	}
}

return PlayerDataTemplate

Now we have a basic definition for our data model. Let’s continue on to see how we can use this definition to load player data.

Loading Player Data When Player Joins

Now we want to load a player’s data when a player joins.

To do this we should understand some things:

  • We’re doing this on the Server, so this code should run by a script parented to ServerScriptService.
  • There’s an event called PlayerAdded that we can use to perform some task when the player joins the game.

I’m going to give the starting script with comments. This script should be a Script parented to ServerScriptService.

--!strict

-- Services
local ServerStorage = game:GetService("ServerStorage")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")

-- ModuleScipts
local ModuleScripts = ServerStorage:WaitForChild("ModuleScripts")
local Dependencies = ModuleScripts:WaitForChild("Dependencies")
local ProfileStore = require(Dependencies:WaitForChild("ProfileStore"))
local PlayerDataTemplate = require(ModuleScripts:WaitForChild("PlayerDataTemplate"))

-- Name the ProfileStore appropriately, I chose "PlayerData" here
local PlayerProfileStore = ProfileStore.New("PlayerData", PlayerDataTemplate)

-- This function will be executed when a player joins our game 
local function OnPlayerAdded(player: Player)
	-- Use a unique key to store the player data. Any string concatenated with the player's UserId should work.
	local profileKey = "Test_" .. tostring(player.UserId)
	-- Fetch the profile from the DB with session locking which prevents issues like item dupes.
	local profile = PlayerProfileStore:StartSessionAsync(profileKey, 
		-- Only use Steal = True for debugging purposes. Setting Steal to true would disable item dupe protection
		{Steal = false, Cancel = function()
			-- ProfileStore will periodically use this function to check if the session needs to be ended. 
			-- Useful for some edge cases.
			return player.Parent ~= Players
		end}
	)
	if not profile then 
		-- If the profile doesn't exist, it's likely because Roblox itself is experiencing issues. 
		-- We don't know what to do with the player if their data doesn't load, so let's kick them.
		player:Kick("Failed to load your data. Please try rejoining after a short wait.")
		return 
	end
	
	profile:AddUserId(player.UserId) -- This is for GDPR Compliance 
	profile:Reconcile() -- The reconcile function applies our default template values to new profiles.
	
	print("Player level:", profile.Data.Level)
	print("Player XP:", profile.Data.XP)
	for _, title in pairs(profile.Data.OwnedTitles) do 
		print("Player owns title:", title.Name)
	end
end

-- Load profiles for players that got in the game before this server script finished running
local function LoadExistingPlayers()
	for _, player in pairs(game.Players:GetPlayers()) do
		task.spawn(OnPlayerAdded, player)
	end
end

LoadExistingPlayers()
game.Players.PlayerAdded:Connect(OnPlayerAdded) -- Connect the function to the player added event

If you did everything correctly, you can now test the experience. The output should look like this when you run the server:

  04:48:52.401  [ProfileStore]: Roblox API services available - data will be saved  -  Server - ProfileStore:2087
  04:48:53.477  Player level: 1  -  Server - TeachMain:40
  04:48:53.477  Player XP: 0  -  Server - TeachMain:41
  04:48:53.477  Player owns title: Noob  -  Server - TeachMain:43
  04:48:53.477  Player owns title: Weakling  -  Server - TeachMain:43
Saving Player Data

Now we can successfully load player data. How do we go about saving player data though?

First, we will need a way to reference a previously created profile. To do this, let’s create a Table which will be used as a Map from Player.UserId to the player’s Profile.

Initialize this table outside the scope of the OnPlayerAdded function:

        local Profiles: {[number]: PlayerDataTemplate.Profile} = {}

Now we need to update Profiles with the Player.UserId and Profile. To do this, when a profile is successfully created, we do this;

        Profiles[player.UserId] = profile

Tying it all together in one script:

--!strict

-- Services
local ServerStorage = game:GetService("ServerStorage")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")

-- ModuleScipts
local ModuleScripts = ServerStorage:WaitForChild("ModuleScripts")
local Dependencies = ModuleScripts:WaitForChild("Dependencies")
local ProfileStore = require(Dependencies:WaitForChild("ProfileStore"))
local PlayerDataTemplate = require(ModuleScripts:WaitForChild("PlayerDataTemplate"))

-- Name the ProfileStore appropriately, I chose "PlayerData" here
local PlayerProfileStore = ProfileStore.New("PlayerData", PlayerDataTemplate)
local Profiles: {[number]: PlayerDataTemplate.Profile} = {}

-- This function will be executed when a player joins our game 
local function OnPlayerAdded(player: Player)
	-- Use a unique key to store the player data. Any string concatenated with the player's UserId should work.
	local profileKey = "Test_" .. tostring(player.UserId)
	-- Fetch the profile from the DB with session locking which prevents issues like item dupes.
	local profile = PlayerProfileStore:StartSessionAsync(profileKey, 
		-- Only use Steal = True for debugging purposes. Setting Steal to true would disable item dupe protection
		{Steal = false, Cancel = function()
			-- ProfileStore will periodically use this function to check if the session needs to be ended. 
			-- Useful for some edge cases.
			return player.Parent ~= Players
		end}
	)
	if not profile then 
		-- If the profile doesn't exist, it's likely because Roblox itself is experiencing issues. 
		-- We don't know what to do with the player if their data doesn't load, so let's kick them.
		player:Kick("Failed to load your data. Please try rejoining after a short wait.")
		return 
	end
	
	Profiles[player.UserId] = profile
	profile:AddUserId(player.UserId) -- This is for GDPR Compliance 
	profile:Reconcile() -- The reconcile function applies our default template values to new profiles.
	
	print("Player level:", profile.Data.Level)
	print("Player XP:", profile.Data.XP)
	for _, title in pairs(profile.Data.OwnedTitles) do 
		print("Player owns title:", title.Name)
	end
end

-- Load profiles for players that got in the game before this server script finished running
local function LoadExistingPlayers()
	for _, player in pairs(game.Players:GetPlayers()) do
		task.spawn(OnPlayerAdded, player)
	end
end

LoadExistingPlayers()
game.Players.PlayerAdded:Connect(OnPlayerAdded) -- Connect the function to the player added event

Now we want to actually save the data. How do we do this? Well, we modify profile.Data directly. ProfileStore will periodically sync the data saved in memory inside of profile.Data to the database. This autosaving feature decreases load on our servers and helps us avoid the rate limits for DataStores.

There’s also another thing we need to do. We need to tell ProfileStore to save the current profile.Data to the database when the profile’s session is ending.

Another issue we have to deal with is memory leaks. Our current code adds an entry to Profiles every time a player joins, but it never removes any entries. This could be an issue if our game is getting lots of visits.

Finally, we need to account for the scenario that the player leaves the game before the profile finished loading.

To do this, we will add the following code:

	if player.Parent ~= Players then 
		-- The player left the game before we finished loading the profile
		profile:EndSession()
	end
	
	profile.OnSessionEnd:Connect(function()
		-- Free up space in Profiles. Without this, we could have a memory leak.
		Profiles[player.UserId] = nil 
		player:Kick(`Profile session end - Please rejoin`)
	end)

Putting it all together:

--!strict

-- Services
local ServerStorage = game:GetService("ServerStorage")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")

-- ModuleScipts
local ModuleScripts = ServerStorage:WaitForChild("ModuleScripts")
local Dependencies = ModuleScripts:WaitForChild("Dependencies")
local ProfileStore = require(Dependencies:WaitForChild("ProfileStore"))
local PlayerDataTemplate = require(ModuleScripts:WaitForChild("PlayerDataTemplate"))

-- Name the ProfileStore appropriately, I chose "PlayerData" here
local PlayerProfileStore = ProfileStore.New("PlayerData", PlayerDataTemplate)
local Profiles: {[number]: PlayerDataTemplate.Profile} = {}

-- This function will be executed when a player joins our game 
local function OnPlayerAdded(player: Player)
	-- Use a unique key to store the player data. Any string concatenated with the player's UserId should work.
	local profileKey = "Test_" .. tostring(player.UserId)
	-- Fetch the profile from the DB with session locking which prevents issues like item dupes.
	local profile = PlayerProfileStore:StartSessionAsync(profileKey, 
		-- Only use Steal = True for debugging purposes. Setting Steal to true would disable item dupe protection
		{Steal = false, Cancel = function()
			-- ProfileStore will periodically use this function to check if the session needs to be ended. 
			-- Useful for some edge cases.
			return player.Parent ~= Players
		end}
	)
	if not profile then 
		-- If the profile doesn't exist, it's likely because Roblox itself is experiencing issues. 
		-- We don't know what to do with the player if their data doesn't load, so let's kick them.
		player:Kick("Failed to load your data. Please try rejoining after a short wait.")
		return 
	end
	
	if player.Parent ~= Players then 
		-- The player left the game before we finished loading the profile
		profile:EndSession()
	end
	
	profile.OnSessionEnd:Connect(function()
		-- Free up space in Profiles. Without this, we could have a memory leak.
		Profiles[player.UserId] = nil 
		player:Kick(`Profile session end - Please rejoin`)
	end)
	
	Profiles[player.UserId] = profile
	profile:AddUserId(player.UserId) -- This is for GDPR Compliance 
	profile:Reconcile() -- The reconcile function applies our default template values to new profiles.
	
	print("Player level:", profile.Data.Level)
	print("Player XP:", profile.Data.XP)
	for _, title in pairs(profile.Data.OwnedTitles) do 
		print("Player owns title:", title.Name)
	end
end

-- Load profiles for players that got in the game before this server script finished running
local function LoadExistingPlayers()
	for _, player in pairs(game.Players:GetPlayers()) do
		task.spawn(OnPlayerAdded, player)
	end
end

LoadExistingPlayers()
game.Players.PlayerAdded:Connect(OnPlayerAdded) -- Connect the function to the player added event

Great! Now let’s test that this is working. Let’s add a simple function that gives the user 1 XP for each second they’re in the game. That code looks like this, add it after the call to profile:Reconcile():

	task.spawn(function()
		while true do 
			task.wait(1)
			profile.Data.XP += 1
		end
	end)

Now you can start your game. Let it run for a few seconds, and then leave the game. Now, in order to test that the data saved properly, let’s rejoin the game so we can see the newly printed out value of XP. Depending on how long you let the XP counter run, you will see a different result for the value of XP. Here’s what the output looked like to me:

  05:35:11.394  [ProfileStore]: Roblox API services available - data will be saved  -  Server - ProfileStore:2087
  05:35:12.427  Player level: 1  -  Server - TeachMain:54
  05:35:12.427  Player XP: 20  -  Server - TeachMain:55
  05:35:12.427  Player owns title: Noob  -  Server - TeachMain:57
  05:35:12.427  Player owns title: Weakling  -  Server - TeachMain:57

Now that we have the ability to save our data to the data store, there’s one final thing we need to do, and that’s handling when the player leaves or the server shuts down.

Handle Player Leaving or Server Shutdown

How will we handle this? Let’s break it down for each case:

  • Player leaving: We can hook an event up to the game.Players.PlayerRemoving signal to perform some task when the player leaves.
  • Server shutdown: ProfileStore handles this for us! Experienced devs might notice I’m leaving out BindToClose. This is because ProfileStore already handles BindToClose, so we don’t need to. This means the scenario where the server shuts down is already handled for us and we don’t need any custom logic.

Now let’s look at how we can save player data when they leave:

Save Player Data When They Leave

Let’s create an OnPlayerRemoving function. In it, we will simply call profile:EndSession. This will trigger one final save to the database before ending the session.

local function OnPlayerRemoving(player: Player)
	local profile = Profiles[player.UserId]
	if profile then 
		profile:EndSession()
	end
end

game.Players.PlayerRemoving:Connect(OnPlayerRemoving) -- Connect the function to the player removing event

Please see the final section for both of the complete scripts!

Final scripts
PlayerDataTemplate (ModuleScript)
--!strict
local ServerStorage = game:GetService("ServerStorage")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

-- Assumes a Folder named ModuleScripts is a child of ServerStorage,
-- a Folder named Dependencies is a child of ModuleScripts, and 
-- a ModuleScript names ProfileStore is a child of Dependencies. 
local moduleScripts = ServerStorage:WaitForChild("ModuleScripts")
local Dependencies = moduleScripts:WaitForChild("Dependencies")
local ProfileStore = require(Dependencies:WaitForChild("ProfileStore"))

-- Defining our own type of profile by passing in our PlayerDataTemplate type
export type Profile = ProfileStore.Profile<PlayerDataTemplate>

export type Title = {
	Name: string,
	-- Date the title was earned 
	EarnedAt: number,
}

-- Type definition for PlayerDataTemplate
export type PlayerDataTemplate = {
	XP: number, 
	Level: number,
	OwnedTitles: { Title },
}

-- Default values of PlayerDataTemplate. Players that are brand new to your game
-- will start out with this data
local PlayerDataTemplate: PlayerDataTemplate = {
	XP = 0,
	Level = 1,
	OwnedTitles = { 
		{Name = "Noob", EarnedAt = tick()},
		{Name = "Weakling", EarnedAt = tick()}
	}
}

return PlayerDataTemplate
Main (Server Script)
--!strict

-- Services
local ServerStorage = game:GetService("ServerStorage")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")

-- ModuleScipts
local ModuleScripts = ServerStorage:WaitForChild("ModuleScripts")
local Dependencies = ModuleScripts:WaitForChild("Dependencies")
local ProfileStore = require(Dependencies:WaitForChild("ProfileStore"))
local PlayerDataTemplate = require(ModuleScripts:WaitForChild("PlayerDataTemplate"))

-- Name the ProfileStore appropriately, I chose "PlayerData" here
local PlayerProfileStore = ProfileStore.New("PlayerData", PlayerDataTemplate)
local Profiles: {[number]: PlayerDataTemplate.Profile} = {}

-- This function will be executed when a player joins our game 
local function OnPlayerAdded(player: Player)
	-- Use a unique key to store the player data. Any string concatenated with the player's UserId should work.
	local profileKey = "Test_" .. tostring(player.UserId)
	-- Fetch the profile from the DB with session locking which prevents issues like item dupes.
	local profile = PlayerProfileStore:StartSessionAsync(profileKey, 
		-- Only use Steal = True for debugging purposes. Setting Steal to true would disable item dupe protection
		{Steal = false, Cancel = function()
			-- ProfileStore will periodically use this function to check if the session needs to be ended. 
			-- Useful for some edge cases.
			return player.Parent ~= Players
		end}
	)
	if not profile then 
		-- If the profile doesn't exist, it's likely because Roblox itself is experiencing issues. 
		-- We don't know what to do with the player if their data doesn't load, so let's kick them.
		player:Kick("Failed to load your data. Please try rejoining after a short wait.")
		return 
	end
	
	if player.Parent ~= Players then 
		-- The player left the game before we finished loading the profile
		profile:EndSession()
	end
	
	profile.OnSessionEnd:Connect(function()
		-- Free up space in Profiles. Without this, we could have a memory leak.
		Profiles[player.UserId] = nil 
		player:Kick(`Profile session end - Please rejoin`)
	end)
	
	Profiles[player.UserId] = profile
	profile:AddUserId(player.UserId) -- This is for GDPR Compliance 
	profile:Reconcile() -- The reconcile function applies our default template values to new profiles.
	
	print("Player level:", profile.Data.Level)
	print("Player XP:", profile.Data.XP)
	for _, title in pairs(profile.Data.OwnedTitles) do 
		print("Player owns title:", title.Name)
	end
	
	task.spawn(function()
		while true do 
			task.wait(1)
			profile.Data.XP += 1
		end
	end)
end

local function OnPlayerRemoving(player: Player)
	local profile = Profiles[player.UserId]
	if profile then 
		profile:EndSession()
	end
end

-- Load profiles for players that got in the game before this server script finished running
local function LoadExistingPlayers()
	for _, player in pairs(game.Players:GetPlayers()) do
		task.spawn(OnPlayerAdded, player)
	end
end

LoadExistingPlayers()
game.Players.PlayerAdded:Connect(OnPlayerAdded) -- Connect the function to the player added event
game.Players.PlayerRemoving:Connect(OnPlayerRemoving) -- Connect the function to the player removing event

Thanks for taking the time to read my tutorial. If you liked it, please consider leaving a like or a comment, as this tutorial took me several hours to make. Also, please let me know if you are interested in more tutorials. I am considering making one on how to integrate Profiles with a more complicated server component.

4 Likes