Comparing libraries
This page sets Geode next to four other Roblox DataStore libraries: ProfileStore, ProfileService, Lapis and DataStore2. It lists what each one does and how, taken from their source code at the versions below. It doesn't rank them. Measured numbers are on Benchmarks, and moving data across is covered in Coming from ProfileStore, ProfileService or Lapis.
Versions read for this page:
| Library | Repository | Version read |
|---|---|---|
| Geode | qvyra/geode | 0.6.1 |
| ProfileStore | MadStudioRoblox/ProfileStore | commit 45c9847 (2025-07-31), the repository has no releases |
| ProfileService | MadStudioRoblox/ProfileService | commit d736800 (2024-10-13), the repository has no releases |
| Lapis | nezuo/lapis | v0.3.3 (commit f140aca), with its dependency evaera/promise 4.0.0 |
| DataStore2 | Kampfkarren/Roblox | ds2-1.4.0 (commit ab81664) |
At a glance
| Feature | Geode | ProfileStore | ProfileService | Lapis | DataStore2 |
|---|---|---|---|---|---|
| Session locking | Yes, a lease in the record | Yes, a session in the record | Yes, a session in the record | Yes, a lock id in the record | No |
| Taking a held lock | Asks the holder, takes it after 15 s by default (stealPolicy) | Asks the holder, takes it after 40 s, or at once with Steal = true | "ForceLoad" asks the holder and takes it after 8 tries, "Steal" takes it now | Retries, then fails. No way to take it | Not applicable |
| A lock nobody renews | Free after leaseTimeout, 300 s by default | Treated as dead after 630 s without an update | Treated as dead after 30 minutes without an update | Free 30 minutes after the key's last update | Not applicable |
| Server hops | MessagingService request plus a MemoryStore key, the old server releases | MessagingService request, the old server saves and ends its session | The old server sees the request on its next save. ListenToHopReady for teleports | The new server keeps retrying until the old one closes | No lock to wait for |
| Schema and validation | Typed schema checked on every write, plus guards | None, a template filled in with Reconcile | None, a template filled in with Reconcile | A validate function, checked on load and save | Checks that saved values are storable |
| Migrations | Keyed by schema version, newer data refused | None | None | A list of migrations, can be marked backwards compatible | None |
| Codecs for Roblox types | Vector3, Vector2, CFrame, Color3, UDim2, DateTime, EnumItem, BrickColor, custom | None, the docs say to serialize first | None, the docs say to serialize first | None | BeforeInitialGet and BeforeSave hooks to write your own |
| Developer products | Built in, purchase ids kept per profile | A documented pattern, not built in | A documented example keeping purchase ids in MetaTags | A documented example keeping purchase ids in the data | Not covered |
| Messages to a profile | store:mail, applied once on the next load, in stores made with mailbox = true | MessageAsync with MessageHandler, also reaches a loaded profile | GlobalUpdates with active and locked stages | None | None |
| Versions and rollback | store:history, store:version, store:rollback, optional audit log | VersionQuery and GetAsync by version, a viewed profile can be written back | ProfileVersionQuery, ViewProfileAsync, OverwriteAsync | No version API | The default OrderedBackups method keeps every save under its own key |
| Replication to clients | Built in, per path | None | None | None | None, OnUpdate callbacks on the server |
| Testing without Roblox | Lune tests on in-memory mocks with fault injection | ProfileStore.Mock inside a Roblox session | ProfileStore.Mock inside a Roblox session | Takes any DataStoreService, its own tests run inside Roblox | Its own tests use a mock DataStoreService inside Roblox |
| Typing | Luau strict, TypeScript typings in the repository | Luau types in the module | Untyped Lua | Luau types in the module | Untyped Lua |
| License | MIT | Apache-2.0 | Apache-2.0 | MIT | Its own license, see below |
| Maintenance status | 0.6.1 released 2026-10-31 | Last commit 2025-07-31 | The README says it's no longer supported and points to ProfileStore | v0.3.3 from 2024-08-24, last commit 2025-02-12 | ds2-1.4.0 from 2021-05-23, last commit 2023-03-17 |
Details
Geode
A profile is one DataStore key holding the data and its bookkeeping. The lease, the data and the purchase ids are written together by one UpdateAsync, so a server whose lease was taken can't write anymore. Loads that find a live holder ask it to release over MessagingService, and a short lived MemoryStore key repeats the answer in case a message is lost. Neither is needed for correctness. See How it works for the full path.
ProfileStore
The session is MetaData.ActiveSession = {PlaceId, JobId, session id} inside the saved record. A server that finds another session writes ForceLoadSession, sends EndSession to the holder on the MessagingService topic PS_<session id>, reads again after FIRST_LOAD_REPEAT (5 s) and then every LOAD_REPEAT_PERIOD (10 s), and takes the session once SESSION_STEAL (40 s) has passed. The holder saves and ends its session when the message arrives, and also when one of its saves sees a pending ForceLoadSession. Autosave runs every AUTO_SAVE_PERIOD (300 s). StartSessionAsync gives up after START_SESSION_TIMEOUT (120 s) of failed requests. In Studio it first writes to a DataStore named ____PS to find out whether API access is on, and runs on an in-memory store when it isn't. It's on Wally as lm-loleris/profilestore, latest 1.0.3 when this page was written.
ProfileService
The session is MetaData.ActiveSession = {PlaceId, JobId}. LoadProfileAsync(key, handler) calls the handler when the profile is held, and the handler answers "Repeat", "Cancel", "ForceLoad" or "Steal". With "ForceLoad" the loading server writes ForceLoadSession and the holder releases when one of its autosaves (spread over AutoSaveProfiles, 30 s) sees it. After ForceLoadMaxSteps (8) tries the loading server takes the session. Every DataStore call for one key waits RobloxWriteCooldown (7 s) after the previous one, so the 8 tries take roughly a minute when the holder never answers. TODO(verify) that time, the bench didn't measure a holder that never answers. In the benchmarks a holder that kept the profile let go after about 21 s, on its next autosave. It doesn't use MessagingService. The README asks new projects to use ProfileStore.
Lapis
The lock is a lockId stored with the data. A load that finds a lock younger than LOCK_EXPIRE (30 minutes, measured from the key's last update) retries loadAttempts (20) times, loadRetryDelay (1 s) apart, then fails with "Could not acquire lock". There's no option to take a held lock. Every request waits until the request budget for its type is above zero, so Lapis queues instead of going over the budget. Pending saves and closes for one key are combined into one request. Data is deep frozen by default and replaced with document:write. Its README warns that it hasn't been used in a large production game yet.
DataStore2
There's no session lock, so two servers can hold and save the same player's data at once. Data is cached per player and saved when the player leaves, on game:BindToClose and on :Save(). The default saving method, OrderedBackups, writes every save to a new key and keeps an OrderedDataStore pointing at the latest one, so old saves stay readable. The Standard method uses one key per player. In Studio it doesn't save unless a SaveInStudio BoolValue in ServerStorage is set. Combine stores several named values under one key.
Its license allows copying and changing the code. When a published game uses a changed copy, the changes have to be published too, for example as a fork on GitHub. Read the license file for the exact terms.