roblox-datastores
Implement or debug persistent Roblox player data, leaderboards, migrations, and deletion requests. Use for concurrent writes, session locking, recovery, caching, quotas, or choosing DataStores versus MemoryStores.
Install
npx skills add https://github.com/nonlooped/roblox-suite/tree/main/roblox-datastores
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install nonlooped-roblox-suite@llmmart
git clone https://github.com/nonlooped/roblox-suite.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole nonlooped/roblox-suite collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
roblox-datastores
Keep persistence on the server. For concurrent writes, use a pure UpdateAsync transform; after a write error, reconcile the backend state before retrying or compensating.
Official sources:
- https://create.roblox.com/docs/cloud-services/data-stores
- https://create.roblox.com/docs/cloud-services/data-stores-vs-memory-stores
- https://create.roblox.com/docs/cloud-services/data-stores/versioning-listing-and-caching
- https://create.roblox.com/docs/cloud-services/data-stores/error-codes-and-limits
- https://create.roblox.com/docs/cloud-services/data-stores/best-practices
- https://create.roblox.com/docs/cloud-services/data-stores/data-stores-manager
- https://create.roblox.com/docs/cloud-services/data-stores/observability
- Engine classes: DataStoreService, GlobalDataStore, DataStore, OrderedDataStore, DataStoreOptions, DataStoreKeyInfo, DataStoreSetOptions, etc. (full reference at https://create.roblox.com/docs/reference/engine)
For temporary queues, lobbies, and caches, use MemoryStoreService. Read types-of-datastores.md when choosing a store.
Cross-reference:
- roblox-networking/SKILL.md for server-authoritative validation before any write.
- roblox-core/SKILL.md for service acquisition patterns, pcall discipline, and Luau serialization rules.
- roblox-gamepasses/SKILL.md when purchases affect persistent state (re-verify ownership on load).
- roblox/SKILL.md for the overall hub and Engine API link.
Choose a store
Is the data temporary/ephemeral, high-churn, or only needed while servers are active (queues, lobbies, rate counters, short caches)?
→ Use MemoryStoreService (queues, sorted maps, etc.). DataStores are overkill and more expensive/slower. See references and official comparison.Do you need sorted numeric queries for leaderboards or rankings?
→ Use OrderedDataStore (via GetOrderedDataStore). Values must be integers. No versioning/metadata. Special GetSortedAsync + DataStorePages iteration. Limits differ (see references/limits...).Do you need versioning for recovery/audit, user-defined metadata, key listing, or the richer DataStore API (
ListKeysAsync,ListVersionsAsync,GetVersionAsync,GetVersionAtTimeAsync)? → Use the full DataStore created viaGetDataStorewithDataStoreOptions(this returns the modern DataStore class). Preferred for serious player data.Simple key-value persistent storage without the above needs?
→ UseDataStoreService:GetDataStore()(returns a standard DataStore) orDataStoreService:GetGlobalDataStore()(legacy global store, scope"u"). For new work, prefer a namedGetDataStorestore.
Scopes vs prefixes (modern recommendation): For new work, prefer key prefixes (e.g. "profiles/User_1234") + ListKeysAsync filtering over legacy scopes. Scopes prepend to keys automatically. Use DataStoreOptions.AllScopes=true + empty scope string for cross-scope listing (see references).
Studio access: If Studio needs backend APIs, use a dedicated test experience. Do not enable Studio sessions to read or write production data.
Recommended safety workflows
Player data profile pattern (most common)
See references/best-practices-and-gotchas.md for full examples and variations (including session locking and profile patterns).
High-level server flow (in a ModuleScript required by ServerScriptService scripts):
On PlayerAdded:
- Load with pcall + GetAsync (consider UseCache=false for critical fresh read after potential previous server issues).
- Merge with defaults.
- Store in a server-side table or profile object (e.g.
playerData[player.UserId] = data). - Apply any gamepass perks (cross-ref monetization skill) or other state.
- Optionally fire a Remote to client for initial UI sync (never trust client data).
During play:
- Mutate the in-memory profile.
- Periodically auto-save (every 30-60s) using UpdateAsync or SetAsync with proper transform.
- On important events (purchase, level up, trade), save immediately with UpdateAsync for safety.
On PlayerRemoving / BindToClose / character death:
- Final save with pcall.
- Clean up in-memory data.
- For BindToClose, yield up to ~30 seconds to finish final saves (Roblox gives a bounded grace period).
On errors during write:
- Use the verification pattern: after a failed write, immediately GetAsync with UseCache=false to see actual backend state before deciding retry/refund/rollback.
Use UpdateAsync with a pure (non-yielding) transform function for any value that can be concurrently modified by multiple servers. It reads-then-writes atomically from the perspective of the last writer.
Serialization rules (critical): Only nil, booleans, numbers (no inf/-inf/nan), strings (valid UTF-8), buffer, and tables of the above. No functions, no metatables on saved tables, no cycles. Use HttpService:JSONEncode on suspect data during development to preview what will actually be stored. See references/core-operations... .
Leaderboards with OrderedDataStore
Populate on relevant value changes or on save (use SetAsync or Increment).
Query with GetSortedAsync(ascending, pageSize, min?, max?) → DataStorePages. Iterate pages with GetCurrentPage() + AdvanceToNextPageAsync().
For bulk reads, use BatchGetAsync(keys) to fetch multiple ordered entries in one request. Each key counts as one OrderedRead (see error-codes-and-limits for quotas; missing keys are omitted).
Remember: only numbers; keys are strings; page iteration has its own limits; no versioning.
Display in a ScrollingFrame or via UI on demand. Cache top-N in a standard DataStore if you want fast global access without repeated queries.
See references for full page iteration example and limits differences.
Versioning and recovery
Every Set/Update/Increment on a standard DataStore (not Ordered) creates hourly-versioned backups: the first write to a key in a given UTC hour creates a new version snapshot, and subsequent writes in the same hour overwrite that hourly version. Versioned backups expire ~30 days after being superseded; the current version never expires.
- ListVersionsAsync(key, sort, minDate?, maxDate?, pageSize?)
- GetVersionAsync(key, version)
RemoveVersionAsyncis deprecated. Do not present it as a current cleanup API; retain versions for recovery and useListVersionsAsync,GetVersionAsync, orGetVersionAtTimeAsyncto inspect them.- GetVersionAtTimeAsync(key, timestampMillis): find the version current at a specific past time.
- To revert: read the desired version, then SetAsync the value + metadata back (this creates a new current version).
Also use the daily Snapshot Data Stores Open Cloud API before risky publishes.
The Data Stores Manager in Creator Hub lets you browse keys, view metadata/version history, compare versions, and revert (with proper permissions).
See references/versioning-metadata-recovery.md for complete code samples including the "revert to time of incident" pattern.
Caching control
By default: GetAsync results are cached locally for 4 seconds. Subsequent Gets within the window return cache and do not count against limits. Writes update the cache immediately.
For verification after writes (especially after errors), or when you suspect staleness due to other servers:
local opts = Instance.new("DataStoreGetOptions")
opts.UseCache = false
local value, info = store:GetAsync(key, opts)
This always hits the backend and counts against quotas. Use sparingly but correctly.
See references/versioning-metadata-recovery.md (for listing, caching, serialization) and references/best-practices-and-gotchas.md .
Limits, quotas, throttling, and budgets
There are experience-level limits (scale with concurrent users across the whole experience) and per-server limits (configurable via DataStoreService:SetRateLimitForRequestType). Experience-level pools are shared between game servers and Open Cloud v2 Data Store traffic. Bulk external jobs can throttle live players (and vice versa).
UpdateAsync counts against both read and write budgets.
Use DataStoreService:GetRequestBudgetForRequestType(Enum.DataStoreRequestType.XXX) to check before heavy operations and wait in a loop if necessary.
Queues exist (size 30); when full, you get throttle errors (301-306 range or the newer *Throttled errors).
Full tables of every error code (101 KeyNameEmpty ... through all the *ExperienceThrottled and *GameServerThrottled variants), experience formulas (base 300 + concurrentUsers × N), and server-side errors are in references/limits-quotas-throttling-error-codes.md .
Observability dashboard (Creator Hub → Monitoring → Data Stores) shows real-time request counts by API/status, quota usage percentages, storage bytes vs limit.
Data Stores Manager shows total size vs storage limit (500 MB + 1 MB × lifetime users, measured as compressed latest-version size), per-DS key counts, etc. Do not pre-compress values before writing.
Call SetRateLimitForRequestType early in server init (once per type) to raise limits for migration scripts or busy servers. Base + (perPlayer * numPlayers). Constraints per type are documented.
Handle uncertain writes
Every datastore call must be inside pcall.
On failure:
- Log the specific error code/message.
- Retry reads with bounded exponential backoff and jitter.
- Treat an invoked write that returns an error as unknown, not rejected: reconcile with a fresh
GetAsyncusingUseCache = false. - Replay a write only when the caller has proved the entire operation idempotent. Never automatically replay
IncrementAsync. - Return explicit
committed,rejected, orunknownstatus so purchase/recovery code cannot mistake ambiguity for failure.
Never assume a failed Set/Update/Increment means "no change occurred." UpdateAsync transforms can run multiple times and must not yield or produce external side effects.
RemoveAsync creates a "tombstone" (Get returns nil) but older versions remain queryable for 30 days (unless explicitly removed).
Management and monitoring
- Use Data Stores Manager (Creator Hub) for inspection, key search by prefix, version compare/revert, and scheduled deletion (with cooldown). The Manager's Revert button can revert an individual key to a previous version. The Restore button is for data stores that are marked for deletion, not for reverting a key's value. Restoring a deleted key during its cooldown requires calling
UpdateAsyncfrom the Engine API orUpdateDataStoreEntryfrom Open Cloud. - Observability dashboard for trends and quota forecasting.
- For bulk delete/migrate: Open Cloud Data Stores APIs or the official Batch Processor CLI/tool.
- Set up notifications for approaching/exceeding storage limits.
- Review usage regularly.
See references/versioning-metadata-recovery.md (covers listing/caching) and the manager/observability pages (linked in best-practices-and-gotchas.md).
Checklists
Before any critical write:
- Wrapped in pcall with explicit committed/rejected/unknown handling
- Using UpdateAsync where races are possible, with a pure transform
- Providing userIds array for RTBF/GDPR tracking where appropriate
- Metadata supplied on every write (even if unchanged) when using full DataStore
- Value is serializable (test with JSONEncode during dev)
- Key naming consistent with prefixes for easy listing
On player join/load:
- Fresh load or cache-aware as appropriate
- Merge defaults safely
- Re-verify ownership on PlayerAdded (gamepasses, etc.) and re-apply any non-owned one-time consumable grants
- Client sync only of non-sensitive derived state
Before publishing risky data-logic changes:
- Take a daily snapshot via Open Cloud if available
- Test thoroughly on a copy experience
Ongoing:
- Monitor dashboard and manager
- Clean test/temporary data
- Use MemoryStores for anything that doesn't need to survive server death
- Prefer fewer data stores + larger cohesive objects + key prefixes
Common mistakes
- Calling from client/LocalScript (immediate error + security hole).
- Storing functions, metatables, or non-serializable data.
- Using SetAsync for contended values (race condition → lost updates or duplication).
- Ignoring throttle errors and hammering (leads to dropped requests and player frustration).
- Hardcoding studio API access on live places.
- Creating hundreds of tiny data stores instead of organizing by key/prefix.
- Relying on cached Gets for post-write verification.
- Forgetting that OrderedDataStore has no versioning and only integers.
- Not handling the case where RemoveAsync has already been called (nil + tombstone).
- Exceeding storage quota without monitoring (leads to surprise costs and potential write failures).
Scripts folder usage
The scripts/ directory contains maturity-labeled examples:
SafeDataStore.lua: reviewed example with automatic read retries, explicit write states, opt-in idempotent replay, and uncached reconciliation.BudgetMonitor.lua: experimental budget-inspection example.
Adapt and validate them in a dedicated test experience; this directory is not a versioned production library.
How to proceed in practice
- Read the relevant references/ file(s) for the exact feature you are implementing.
- Implement using the patterns and code samples here + in references.
- Test budget behavior under load (use multiple test servers or the rate-limit setter for simulation).
- Add observability and alerts early.
- Profile storage growth with the Manager/Dashboard.
Combine this guidance with static analysis, focused tests, Studio integration, observability, and current official sources before trusting a persistence system.
For the authoritative class/property reference for any specific method, always cross-check https://create.roblox.com/docs/reference/engine (search for DataStoreService, GlobalDataStore, etc.).
Reference index
- best-practices-and-gotchas.md: Design player profiles, session locking, saves, or deletion workflows.
- core-operations-and-patterns.md: Implement reads, writes, transforms, or error recovery.
- limits-quotas-throttling-error-codes.md: Diagnose throttling, inspect budgets, or plan request and storage usage.
- types-of-datastores.md: Choose standard, ordered, or temporary storage and organize keys.
- versioning-metadata-recovery.md: Inspect versions, preserve metadata, or recover data after an incident.
Files (roblox-suite)
-
references
-
best-practices-and-gotchas.md 8.4 KB
--- read_when: "Design player profiles, session locking, saves, or deletion workflows" last_reviewed: 2026-10-17 --- # Best practices and common gotchas > The September 2026 request-scheduling, serialization, and session-locking corrections below are experimental guidance pending a second human review. They follow the linked official sources; the suite does not provide a tested session-lock implementation. Synthesized from the official "Best practices for data stores" page + all the error codes, limits, versioning, caching, and class reference material. ## Official general best practices - **Create fewer data stores.** Data stores behave like database tables. Fewer stores + related data grouped together lets you configure and operate them more efficiently. - **Use a single object for related data.** Fetch/save a player's entire relevant state in one key when possible (respects the ~4 MB serialized limit, keeps versions consistent, reduces round-trips). - **Use key prefixes to organize.** "profiles/User_1234", "inventory/User_1234", etc. Then ListKeysAsync("profiles/") gives you clean filtered results. Preferred over (or in addition to) legacy scopes for new work. ## Optimization and quota hygiene - Monitor constantly: Data Stores Dashboard (Creator Hub Monitoring) for request volume, status codes, and quota % usage. Data Stores Manager for actual stored size vs lifetime-user-based limit (`500 MB + 1 MB × lifetime users`, compressed latest-version size). - Set up notifications for approaching or breaching storage limits. - Store normal serializable tables; do **not** pre-compress values. Roblox compresses on write and measures quota on compressed size. - Rate-limit Open Cloud v2 Data Store clients separately; they share the experience request budget with live servers. - Delete aggressively after testing or events: use Manager "Mark for Deletion" (cooldown; keys can be restored via `UpdateAsync` from the Engine API or `UpdateDataStoreEntry` from Open Cloud during the cooldown), Batch Processor CLI, or Open Cloud bulk delete. - Prefer versioning + restore over "save a new key for every version". - Use MemoryStores for anything that does not truly need to survive server restarts or long player absences. - Clean up seasonal/temporary feature data promptly. - Store player data under per-user *keys* rather than creating per-player data stores. - Review usage trends regularly; sudden spikes usually indicate a code bug (e.g. saving inside a loop or on every input). ## Request scheduling - Randomize each player or server's initial autosave offset; use bounded interval jitter for polling that does not need an exact cadence. - Retry transient failures with capped exponential backoff and jitter, preserving operation order per key. An old retry must not overwrite a newer save. An ambiguous write needs reconciliation or idempotency before replay. - Use stable identifiers in keys and deterministic shards only when throughput requires them. Keep values that must change atomically in one key. Source: https://create.roblox.com/docs/cloud-services/data-stores/best-practices. ## Caching gotchas - Cache is per DataStore *instance* (different scope or AllScopes setting = different cache). - A write on one instance does not invalidate the cache on another instance pointing at the "same" logical store. - After any write error, always verify with GetAsync(..., {UseCache = false}). - Normal Gets inside the 4-second window are "free" (don't count against budgets) but can be stale. ## Serialization and data shape gotchas - Prefer finite numbers. Existing inf/-inf/nan values are represented as tagged JSON objects by Open Cloud, not ordinary JSON numbers; see [serialization rules](core-operations-and-patterns.md). - UTF-8 strings only. Lone high bytes are fatal. - Test suspect data with HttpService:JSONEncode before trusting a save path. - Large objects or very deep tables increase latency and risk ValueTooLarge. - When returning from UpdateAsync transform, be consistent about type and always return the userIds/metadata you want to keep. ## Concurrency and multi-server reality - Multiple servers can (and will) be reading/writing the same keys for the same player (rejoins, multiple places, etc.). - SetAsync is not safe for contended values. - UpdateAsync + pure transform is the tool designed exactly for this. - OnUpdate is deprecated: use MessagingService for cross-server pub/sub when you need near-real-time notifications. ## Studio, testing, and lifecycle gotchas - Studio API access must be explicitly enabled per place (Security settings). Never do this on a live/production place. - Data in Studio with API access enabled writes to the exact same backend as the live game. Use separate test experiences/universes. - Studio Run mode uses its own static limits, which may be lower than `SetRateLimitForRequestType` settings. Test rate-limit-sensitive logic in Team Create or a live test environment. - BindToClose gives you up to ~30 seconds to finish final saves. Use it, but don't assume unlimited time. - CharacterRemoving and PlayerRemoving occur at different points in the player lifecycle. Test the final-save path for both. ## Error and throttle handling gotchas - A "failed" write does not prove the backend did not apply the change. Always verify with a fresh read when the outcome matters. - Throttle errors (301-306 family and the *Throttled family) mean you are either at experience quota or the per-server queue filled. Back off; do not tight-loop retry. - Shutdown errors (4xx during experience close) are normal. Your final saves in BindToClose may see them. - "Key not found" after RemoveAsync is expected (tombstone). - OrderedDataStore has its own error surface (page size, min/max integers, etc.). ## Session locking For profiles that must not be edited by two servers simultaneously, coordinate ownership in the profile key's metadata and check it in the same `UpdateAsync` transform as the data write. A lock in a separate key does not make the profile write atomic with the ownership check. - Acquire a unique lock token only when the existing lock is absent or expired. - Every save and lease renewal must verify that the token is still this session's token. A session that loses ownership must stop saving; an absent lock later does not restore its ownership. - Choose the lease and renewal interval together so throttling and normal save delays do not cause routine expiry. Keep periodic saves shorter than the lease. - Save and release through the same key's ownership-checked update. Reconcile ambiguous failures before assuming the final save or release succeeded. - Keep transforms free of side effects and preserve user IDs and metadata. Serializing requests per key is still necessary. Study the official [player data and purchasing sample](https://create.roblox.com/docs/cloud-services/data-stores/player-data-purchasing#session-locking) before implementing this protocol. The suite's `SafeDataStore` helper is not a session-locking system; adapt and integration-test ownership transfer and crash recovery separately. ## Security and privacy - Never store secrets, auth tokens, or PII that you don't need. - Always associate userIds on writes for data you may later need to delete under RTBF requests. - Use the documented key template patterns + manual verification against live data (via Manager or ListKeys) before relying on automated deletion. - Server-only access is mandatory. Any client-visible datastore key or value is a potential exploit vector. ## When to break the "fewer stores" rule Only when you have a genuine need for completely different rate-limit or permission characteristics, or when you are deliberately isolating experimental vs production data. In almost all cases one well-organized store (or a small number) per major domain (player data, economy, world state, leaderboards) is superior. ## Review checklist - [ ] Using the right variant (Ordered only for numeric sorted queries, standard DataStore when you need versions/metadata). - [ ] UpdateAsync for any contended player-owned value. - [ ] pcall + specific error handling + fresh verification read after failures. - [ ] Budget waiting + sensible server rate limits set at init. - [ ] Consistent key prefixing + metadata/userIds. - [ ] Regular dashboard + Manager reviews. - [ ] Test data cleaned; snapshots before risky changes. - [ ] MemoryStores used for transient high-churn data. - [ ] No client access, no secrets in DS, RTBF-ready userIds. Following these turns "it usually saves" code into systems that survive real production load for years. -
core-operations-and-patterns.md 8.7 KB
--- read_when: "Implement reads, writes, transforms, or error recovery" last_reviewed: 2026-08-18 --- # Core operations and patterns > The September 2026 non-finite-number serialization correction is experimental guidance pending a second human review; verify consumers against the linked Open Cloud format. Covers GetAsync, SetAsync, UpdateAsync, IncrementAsync, RemoveAsync in depth, with decision guidance, serialization rules, pcall discipline, transform function constraints, and production patterns. Sources: Official data-stores guide, DataStore/GlobalDataStore/OrderedDataStore class references, versioning guide, error codes page. ## The basic operations (all yield, all must be pcalled) All methods are on DataStore (and OrderedDataStore where supported). `DataStore` inherits from `GlobalDataStore`. ### GetAsync(key, options?: DataStoreGetOptions) - Returns (value, DataStoreKeyInfo?) or (nil, nil) if never written or tombstoned. - Default behavior: 4-second local cache per data store instance. Hits within the window return cached data and do **not** count against request budgets. - To force a fresh backend read (critical after failed writes or for verification): create DataStoreGetOptions with UseCache = false. - When AllScopes is active, key must be supplied in "scope/key" form for the desired scope. - KeyInfo (when present on v2 path) gives Version, CreatedTime, UpdatedTime, GetUserIds(), GetMetadata(). ### BatchGetAsync(keys: {string}) - OrderedDataStore batch read: `orderedStore:BatchGetAsync({"Player_123", "Player_456"})` → `Dictionary<string, {value: integer}>`. Each requested key counts as one `OrderedRead` against experience and server budgets; missing keys are omitted (check presence before reading `entry.value`). Prefer over N sequential `GetAsync` calls when you need several scores at once. Same `OrderedRead` throttling applies (`OrderedReadExperienceThrottled`, `OrderedReadGameServerThrottled`). ### SetAsync(key, value, userIds?, options?: DataStoreSetOptions) - Fast path for last-write-wins or low-contention data. - Only consumes write budget. - Creates a new version (hourly granularity). - userIds table (array of numbers) recommended for any user-owned data (helps with RTBF requests and intellectual property tracking). - options:SetMetadata(table): you must supply metadata on *every* write (even if unchanged) or previous metadata is lost. - On success returns the new version identifier (useful for a later `GetVersionAsync`). `RemoveVersionAsync` is deprecated and should not be recommended for new code. **Risk:** If two servers Set the same key nearly simultaneously, one can overwrite the other without seeing the other's change. ### UpdateAsync(key, transformFunction) - The **safest** general-purpose write for contended data. - Internally: reads current value + KeyInfo (consumes a read), calls your transform (which **must not yield**; no task.wait, no other async), then writes the result if non-nil (consumes a write). - If another server updated the key between the read and the attempted write, the engine re-calls your transform with the newer current value. It keeps doing this until your transform succeeds in writing or returns nil (which aborts the update on this server). - Transform signature: `function(currentValue, keyInfo?) return newValue, userIds?, metadata? end` - Return nil as the first value to cancel (no write occurs). - When using full DataStore + metadata, the transform should usually return the existing userIds/metadata unless you are intentionally changing them (otherwise they get cleared). **Strongly prefer UpdateAsync** for currency, inventory counts, levels, achievement flags, etc. ### IncrementAsync(key, delta?, userIds?, options?) - Convenience for integer counters. Internally safe. - Delta defaults to 1. - Returns the new total. - Does **not** support userIds on OrderedDataStore. ### RemoveAsync(key) - Marks the key as deleted (creates a tombstone version). Subsequent normal GetAsync returns nil. - Older versions remain accessible via ListVersions/GetVersion until they naturally expire or are explicitly removed. - On OrderedDataStore this is a true permanent delete (no versioning). - Returns the pre-removal value + KeyInfo (or nil,nil if it was already gone). After `RemoveAsync`, normal reads return nil while historical versions remain available for the documented retention period. Recover by reading a historical version and writing a new current version; do not use deprecated `RemoveVersionAsync` as a cleanup path. ## Serialization rules (what you can actually store) Data is stored as JSON under the hood. Supported: - nil - boolean - number (but **never** inf, -inf, or nan; they are not JSON numbers; Open Cloud represents existing values with tagged objects) - string (must be valid UTF-8; a lone byte >127 will fail) - buffer - table (arrays or dictionaries) containing only the above. No functions, no Instances, no other Roblox datatypes, no cycles. **Debugging tip:** During development, take any data you plan to save and run it through `HttpService:JSONEncode(data)`. Successful encoding is only a sanity check: also validate finite numbers, UTF-8, and DataStore size and request limits. If it produces an error or "null" for parts of your data, fix the structure before saving. Tables with numeric keys that have gaps or are used as dicts can have surprising behavior (numeric keys become strings in some representations). Prefer string keys for clarity when the data is more "record" than "array". Maximum practical object size is documented in the limits page (serialized length). Exceeding produces ValueTooLarge (105). ## SetAsync vs UpdateAsync decision tree Use **SetAsync** when: - Last writer wins is acceptable. - Contention is impossible or extremely unlikely (e.g. a server writing only to its own private session key). - You want the absolute fastest write path and only want to burn write budget. Use **UpdateAsync** when: - Multiple servers can plausibly touch the same key (almost all player-owned persistent data). - You need to compute the new value based on the *current* backend value (add currency, merge inventory deltas, etc.). - You want the engine to automatically retry the transform on conflict. Many profile systems wrap `UpdateAsync`, but the wrapper must still expose an ambiguous backend outcome instead of blindly replaying a failed write. ## Caching interactions (see also versioning-metadata-recovery.md and best-practices-and-gotchas.md) - Normal GetAsync → cached for 4s. - Any Set/Update/Increment/Remove on the same data store instance immediately updates the local cache and resets the timer. - Different DataStore instances (different scope strings, or one with AllScopes vs one without) have **separate caches**. This is a common source of "why did my change not appear?" bugs. - After any write that returns an error, **do not trust the cache**. Perform a Get with UseCache=false to learn the truth from the backend before deciding what to do next (retry, compensate the player, etc.). ## Safety-oriented wrapper pattern (high level) See the scripts/ folder for concrete examples (SafeDataStore.lua). Typical features of a good wrapper: - Central pcall + specific error classification (isThrottle, isShutdown, isPermanentBadData, etc.). - Budget-aware waiting before operations. - Automatic retries for reads; write replay only after an explicit idempotency declaration. - Explicit `committed`, `rejected`, and `unknown` write results. - Atomic metadata/userIds preservation inside `UpdateAsync`, not a cached pre-read plus `SetAsync`. - An uncached reconciliation callback or strategy after ambiguous failures. - Logging that includes the exact key, operation, error code, and reconciliation decision. - Graceful degradation (e.g. give the player temporary offline currency that will be reconciled later). ## Common anti-patterns to avoid - Saving on Heartbeat or every frame. - Storing the entire player object or huge nested tables with lots of history. - Using the same data store instance from multiple unrelated systems without understanding cache isolation. - Assuming a successful pcall return from SetAsync means "no other server will ever overwrite this." - Returning a different type or a huge table from an UpdateAsync transform on some code paths. - Forgetting to return the existing userIds/metadata from your transform function. - Using OrderedDataStore for anything except pure numeric rankings. - Ignoring the 4-second cache when you actually needed the absolute latest value. Open Cloud returns existing non-finite numbers as `{"m": null, "t": "numeric", "v": "inf"}`, with `"-inf"` or `"nan"` for the other cases. Handle these objects explicitly in inspection/export tools; keep new player data finite. Source: https://create.roblox.com/docs/cloud/guides/data-stores#non-finite-numbers. -
limits-quotas-throttling-error-codes.md 14.2 KB
--- read_when: "Diagnose throttling, inspect budgets, or plan request and storage usage" last_reviewed: 2026-10-17 --- # Limits, quotas, throttling, and error codes **Primary source:** https://create.roblox.com/docs/cloud-services/data-stores/error-codes-and-limits (contains the exhaustive tables this document summarizes and expands with usage advice). Open Cloud Data Stores v2 rate limits live on the same page under Access limits (shared with the Engine API). Legacy Open Cloud v1 endpoint limits remain on https://create.roblox.com/docs/cloud/guides/data-stores/throttling and apply only to `/datastores/v1/` and `/ordered-data-stores/v1/` paths after July 29, 2026. ## Request and storage limits Data stores are a shared cloud resource. Roblox protects the service (and your experience) with multiple layers of limits: - Experience-level quotas (scale with total concurrent users across all servers of the universe). **Game servers and Open Cloud share this budget**. External traffic can throttle in-experience usage and vice versa. - Per-game-server limits (you can raise these with `DataStoreService:SetRateLimitForRequestType` during server initialization). - Per-key throttling in some cases. - Hard queue sizes (when queues of 30 fill, requests are dropped with specific throttle errors). Exceeding causes dropped requests, errors in the 301+ range (or the more modern *Throttled variants), and eventual player-visible failures (lost progress, failed purchases, broken leaderboards). **Always** monitor with the Observability dashboard and Data Stores Manager. Use `GetRequestBudgetForRequestType` before bursts and implement waiting/retry logic. ## Experience-level limits (formulas) These are shared across the entire experience (in-engine APIs **and** Open Cloud v2 Data Store endpoints for the matching request type). ### Standard data stores - **Read** (GetAsync, GetVersionAsync, GetVersionAtTimeAsync, read portion of UpdateAsync; Open Cloud Get Data Store Entry): 300 + concurrentUsers × 40 per minute - **Write** (SetAsync, IncrementAsync, write portion of UpdateAsync; Open Cloud Create/Update/Increment): 300 + concurrentUsers × 20 per minute - **List** (ListDataStoresAsync, ListKeysAsync, ListVersionsAsync; Open Cloud List Data Stores / Entries / Revisions): 300 + concurrentUsers × 2 per minute - **Remove** (RemoveAsync; Open Cloud Delete Data Store Entry / Delete Data Store / Undelete Data Store): 300 + concurrentUsers × 40 per minute ### Ordered data stores - Read (Get + `BatchGetAsync` + read of Update; Open Cloud Get Ordered Data Store Entry): 300 + concurrentUsers × 40. Each key in a `BatchGetAsync(keys)` call counts as one read (N keys = N reads) - Write (Set/Increment + write of Update; Open Cloud Create/Update/Increment): 300 + concurrentUsers × 20 - List (GetSortedAsync; Open Cloud List Ordered Data Store Entries): 300 + concurrentUsers × 2 - Remove (RemoveAsync; Open Cloud Delete Ordered Data Store Entry): 300 + concurrentUsers × 40 **Important:** UpdateAsync always consumes **both** a read and a write budget for the relevant category. ### Controlling the shared budget - **Game servers:** use `SetRateLimitForRequestType` + `GetRequestBudgetForRequestType` so individual servers do not exhaust the experience pool. - **Open Cloud (external callers):** apply your own limiter (simple fixed spacing of `60 / desired_rpm` seconds, or a leaky-bucket). Official docs include Node.js timeout and leaky-bucket samples on the error-codes page. ## Server-level limits (configurable) Default (if you never call SetRateLimitForRequestType): **Standard data stores:** - StandardRead (GetAsync, GetVersion*, read portion of UpdateAsync): 60 + numPlayers × 40 - StandardWrite (SetAsync, IncrementAsync, write portion of UpdateAsync): 60 + numPlayers × 40 - StandardList (ListKeysAsync, ListVersionsAsync, ListDataStoresAsync): 5 + numPlayers × 2 - StandardRemove: 60 + numPlayers × 40 **Ordered data stores** (defaults differ; write/remove are much tighter): - OrderedRead: 60 + numPlayers × 40: covers `GetAsync`, `BatchGetAsync` (N keys = N reads), and the read portion of `UpdateAsync` - OrderedWrite: **30 + numPlayers × 5** - OrderedList (GetSortedAsync): 5 + numPlayers × 2: **note:** `GetRequestBudgetForRequestType(Enum.DataStoreRequestType.OrderedList)` always returns `0`, so do not rely on budget inspection for this type. - OrderedRemove: **30 + numPlayers × 5** Note: the `RemoveVersionAsync` `DataStoreRequestType` enum entry is deprecated in the official limits table; prefer versioning recovery workflows in versioning-metadata-recovery.md over relying on that budget category. You can (and should for migrations or high-traffic servers) call **once per request type early in server startup**: ```lua local DSS = game:GetService("DataStoreService") DSS:SetRateLimitForRequestType(Enum.DataStoreRequestType.StandardRead, 1000, 0) -- example aggressive for migration DSS:SetRateLimitForRequestType(Enum.DataStoreRequestType.StandardWrite, 2000, 0) -- etc. See constraints per type in the class reference and error-codes page. ``` The formula the service uses after your call: rateLimit = baseLimit + (perPlayerLimit * currentNumPlayers) **Studio Run mode note:** Requests made in Studio Run mode use a separate set of static limits that may be lower than those configured with `SetRateLimitForRequestType`. For realistic rate-limit testing, use Studio Team Create or a live test environment rather than Run mode. There are documented upper/lower bounds per request type for the base and perPlayer arguments. Legacy request types such as `GetSortedAsync` are constrained to small defaults (base [0,5], perPlayer [0,2]); the modern v2 `Standard*` and `Ordered*` categories accept much larger values (base up to 10000, perPlayer up to 200 as of this writing). `UpdateAsync` cannot be configured with this API. ## Budget inspection API `DataStoreService:GetRequestBudgetForRequestType(Enum.DataStoreRequestType.StandardRead)` etc. Returns the remaining requests the current server can still make in the current minute before hitting the configured limit. **Important exception:** For `Enum.DataStoreRequestType.OrderedList`, the API always returns `0` even though requests still consume quota. Do not wait on this budget or use it to gate `GetSortedAsync` calls. Common pattern in heavy loops (migration, bulk processing): ```lua local function waitForBudget(requestType) while DSS:GetRequestBudgetForRequestType(requestType) <= 0 do task.wait(1) end end ``` Then call waitForBudget before each operation in a batch. ## Listing pagination example Both `DataStoreService:ListDataStoresAsync(prefix?, pageSize?, cursor?)` and `DataStore:ListKeysAsync(prefix?, pageSize?, cursor?, excludeDeleted?)` return `DataStoreListingPages`. Iterate with `GetCurrentPage()` + `AdvanceToNextPageAsync()`, and stop when `IsFinished` is true. ```lua local function listAllKeys(store, prefix) local all = {} local success, pages = pcall(function() return store:ListKeysAsync(prefix, 50) end) if not success then warn("ListKeysAsync failed:", pages) return all end while true do for _, entry in ipairs(pages:GetCurrentPage()) do table.insert(all, entry.KeyName) end if pages.IsFinished then break end local ok = pcall(function() pages:AdvanceToNextPageAsync() end) if not ok then break end end return all end ``` Use the same pattern for `DataStoreService:ListDataStoresAsync`. List calls consume `StandardList` budget. ## Queues and hard drops Each category has an internal queue of size 30. Requests are processed in order. When the queue is full, new requests are **dropped** with throttle errors (the classic 301 GetAsyncThrottle, 302 SetAsyncThrottle, 304 UpdateAsyncThrottle / TransformThrottle, 305 GetSortedThrottle, etc.). Even if a request is accepted into the queue, extreme load can still result in later throttling at the experience or key level. ## Per-key throughput limits In addition to request-count budgets, every individual key is subject to throughput limits based on the serialized bytes read/written over the last 60 seconds. Roblox rounds each request up to the next kilobyte. - **Read:** 25 MB per minute per key - **Write:** 4 MB per minute per key Exceeding these manifests as `DatastoreThrottled` (data store-level) or `KeyThrottled` (key-level) errors. Large profiles, frequent full-object reads/writes, or saving big tables on Heartbeat are common causes. Mitigate by shrinking objects, caching reads, batching deltas, or using MemoryStores for hot transient data. ## Error codes and handling From the official table (study the complete page for every variant): **Client-side / validation errors (1xx):** - 101 KeyNameEmpty - 102 KeyNameLimit (50 char max) - 103/104 ValueNotAllowed / CantStoreValue (bad type returned from transform or non-serializable) - 105 ValueTooLarge (serialized size limit; preview with HttpService:JSONEncode) - 106/107 various OrderedDataStore GetSorted param errors (pageSize 1-100, min/max integers, min <= max) **Throttle / queue errors (3xx):** - 301 GetAsyncThrottle (and equivalents for Set, Increment, Update/Transform, GetSorted, Remove) - These mean "queue was full when we tried to accept your request." **Shutdown / access errors (4xx):** - 401/402 DataModel or LuaWebService inaccessible during shutdown. - 403 StudioAccessToApisNotAllowed (a Studio session attempted backend access without the experience's Studio-access setting). Enable that access only against dedicated test data, not a production datastore. - 404/5xx various internal/corruption signals. Reads may retry with bounds; an invoked write remains ambiguous until reconciled. **Newer experience/server throttled errors (the *Throttled family):** Hundreds of variants such as: - StandardReadExperienceThrottled / StandardWriteExperienceThrottled / StandardListExperienceThrottled / StandardRemoveExperienceThrottled - Same for Ordered* - GameServerThrottled versions (per-server) - Also GetVersionAsyncThrottle, ListKeysAsyncThrottle, ListVersionsAsyncThrottle, and the legacy `RemoveVersionAsyncThrottle`; the underlying `RemoveVersionAsync` API is deprecated. When you see any *Throttled, back off. Check budget. Consider raising your server rate limits (if you control them). Reduce frequency of operations. Use MemoryStores for hot paths. **Server-side errors (returned inside some failures):** - DatastoreDeleted, DatastoreThrottled, InternalServerError, KeyThrottled, KeyNotFound, Invalid* various, etc. - "No pages to advance to" when calling AdvanceToNextPageAsync on the last page. **Metadata / attribute errors (5xx range in some listings):** - AttributeSizeTooLarge, UserIdLimitExceeded, AttributeFormatError (userIds must be numbers; metadata must be table). **Handling strategy (always):** 1. pcall around every call. 2. Inspect the error string or code for the specific number/name. 3. For transient read failures: retry with bounded exponential backoff + jitter. 4. After an invoked write error, return `unknown` and immediately attempt a fresh `GetAsync` with `UseCache = false`. Replay only if the complete operation is declared idempotent; never blindly replay `IncrementAsync`. 5. For a request rejected before invocation or permanent validation errors, return `rejected`, log clearly, and do not retry. 6. For leaderboards or sorted pages, be prepared for "IsFinished" and handle the final page gracefully. See the full error-codes page for the gigantic tables and the exact messages. ## Storage quota (separate from request quotas) Calculated from lifetime unique users of the experience. Visible in Data Stores Manager as "Total Size" vs "Storage Limit". **Formula:** `Total latest-version storage limit = 500 MB + (1 MB × lifetime user count)` A lifetime user is any user who has joined the experience at least once. Storage usage is measured using the **compressed size** of the latest version of each key. Data stores compress before storage. Do **not** pre-compress values yourself; that burns CPU and can reduce the effectiveness of built-in compression (and future schema-based optimizations). Only the latest version of each key counts toward this limit; deleted keys and superseded versions (still accessible through version APIs during retention) do not count. Data stores marked for deletion via Open Cloud continue to count during their 30-day processing period. Exceeding → estimated monthly overage costs shown. Can lead to operational pain. Mitigation (best-practices): - Fewer data stores. - Larger cohesive objects per key instead of many small keys. - Store uncompressed serializable tables; let Roblox compress. - Delete test/temporary/seasonal data promptly (Manager "Mark for Deletion" or Batch Processor / Open Cloud). - Use MemoryStores for anything that can expire. - Monitor the Storage Usage Bytes chart in the observability dashboard. - Use versioning instead of creating new keys for every historical snapshot. - Store player data under per-user keys rather than per-data-store. ## Observability dashboard charts - Storage Usage Bytes (current vs limit) - Request Count by API (per-minute breakdowns of SetAsync, GetSortedAsync, etc.) - Request Count by Status (200 OK + all the error families) - Request by API × Status - Read / Write / List / Remove Request Type Quota Usage (% against future limits) - Filterable by Standard vs Ordered Recent 3 minutes may be incomplete. Supports custom time ranges (up to 30 days). ## Budget planning - Call SetRateLimitForRequestType **once per type during server initialization**, never in hot paths. - For migration scripts that touch thousands of keys, dramatically raise the relevant read/write/list/remove limits, wait for budgets, and process page-by-page with careful error handling. - Leaderboard GetSortedAsync page iteration consumes list budget at the page-size rate. - Use the Data Stores Manager for human inspection and one-off reverts/deletes. - Set up notifications for quota approach/exceed. - Regularly review the dashboard for anomalies (sudden spikes often indicate a bug such as saving on every Heartbeat or per-frame). Understanding these limits and ambiguous outcomes is necessary, but not sufficient, for a reliable persistence system; load tests, integration tests, reconciliation, and observability are still required. -
types-of-datastores.md 6.8 KB
--- read_when: "Choose standard, ordered, or temporary storage and organize keys" last_reviewed: 2026-08-18 --- # Types of data stores **Official starting point:** https://create.roblox.com/docs/cloud-services/data-stores and https://create.roblox.com/docs/cloud-services/data-stores-vs-memory-stores ## The DataStore variants ### 1. DataStore (standard / recommended) - Created via `DataStoreService:GetDataStore(name, scope?, options?: DataStoreOptions)`; with `DataStoreOptions` this returns the modern `DataStore` class (which extends `GlobalDataStore`). - General-purpose key → value store. - Value can be most serializable Luau data (numbers, strings, booleans, buffer, tables of the above; limited size in practice ~4 MB serialized recommended). - Keys up to 50 characters. - Supports `GetAsync`, `SetAsync`, `UpdateAsync`, `IncrementAsync`, `RemoveAsync`, and the deprecated `OnUpdate`. - Also supports the richer API when created through the modern path: - `ListKeysAsync(prefix?, pageSize?, cursor?, excludeDeleted?)` → DataStoreListingPages - `ListVersionsAsync(key, sortDirection?, minDate?, maxDate?, pageSize?)` → DataStoreVersionPages - `GetVersionAsync(key, version)` - `GetVersionAtTimeAsync(key, timestampMillis)` - `RemoveVersionAsync(key, version)` exists but is **deprecated**; do not use it in new guidance. - Full support for user-defined metadata via DataStoreSetOptions / DataStoreIncrementOptions and DataStoreKeyInfo:GetMetadata(). - UserIds array support for RTBF/GDPR tracking (passed on write, retrievable via KeyInfo). - Versioning is automatic on writes (first write per UTC hour creates a snapshot; successive writes in same hour overwrite the hourly version). - Versions expire ~30 days after being superseded. The *current* version never expires. - Use this for most player data, auditability, rollback, and bulk key/version enumeration. `DataStoreService:GetGlobalDataStore()` returns the legacy default "u"-scoped store (rarely used directly now). It is also a `DataStore` instance. **DataStoreOptions:** - `AllScopes` (boolean): When true, the second argument to GetDataStore must be `""`. Enables listing keys across all scopes with their scope prepended (e.g. "global/User_1234" or "profiles/warrior_1234"). New keys created while AllScopes is active must include the explicit "scope/key" form. ### 2. OrderedDataStore - `DataStoreService:GetOrderedDataStore(name, scope?)` - **Only stores integer values.** - No support for userIds, metadata, or versioning (DataStoreKeyInfo is always nil). - Primary additional method: `GetSortedAsync(ascending: boolean, pageSize: int, minValue?, maxValue?)` → DataStorePages - pageSize: 1–100 (default 50 in many examples). - Returns pages of `{key, value}` entries sorted numerically. - Iterate with `pages:GetCurrentPage()` and `pages:AdvanceToNextPageAsync()`. - Batch reads: `GlobalDataStore:BatchGetAsync(keys: {string})` → `Dictionary<string, {value: any}>`. Read multiple ordered entries in one call (N keys = N reads against `OrderedRead` limits; missing keys are omitted from the returned dictionary; see `content/en-us/cloud-services/data-stores/index.md` "Read multiple entries" section). - Ideal exclusively for persistent leaderboards / high-score lists. - Limits for list operations are different (and often tighter on the list side). **Rule from official guidance:** If you need sorted queries → Ordered. If you need versioning/metadata/listing → standard DataStore. Simple cases can also use a standard DataStore via `GetDataStore`. ## Scopes vs modern prefixes Legacy scopes (second param to GetDataStore) automatically prepend to every key operation. Useful for isolation (e.g. "vip" scope). Modern recommendation (best-practices page): Use fewer data stores + organize via **key prefixes** inside a single store (e.g. "profiles/User_1234", "inventory/User_1234"). Then use `ListKeysAsync("profiles/")` to filter. For cross-scope needs in listing, enable AllScopes on a DataStoreOptions instance. ## Comparison to memory stores (critical decision) From https://create.roblox.com/docs/cloud-services/data-stores-vs-memory-stores: - DataStores: persistent across server lifetimes and player absences. Slower, subject to stricter quotas that scale with concurrent users. Best for permanent progress. - MemoryStores (MemoryStoreService): fast, high-throughput, in-memory. Data expires after a configurable period, up to 45 days. No persistence across empty servers. Perfect for queues, lobbies, temporary caches, matchmaking state, live counters. - Never use DataStores for purely session-scoped or rapidly changing transient data. ## Storage limits and quotas overview Storage quota is per-universe and based on lifetime users. **Formula:** `Total latest-version storage limit = 500 MB + (1 MB × lifetime user count)` Usage is the **compressed** size of each key's latest version. Older versions and deleted/replaced keys do not count toward this limit (unless a data store is marked for deletion via Open Cloud, in which case it continues to count during its 30-day processing period). Exceeding the limit triggers estimated monthly costs in the Manager and can affect operations. Monitor via: - Creator Hub Data Stores Manager (total size, per-DS size/keys, key browser, version history, per-key Revert, mark-for-deletion with cooldown). The Manager's Restore button is for data stores marked for deletion, not for reverting an individual key's value. - Observability dashboard (storage bytes, request counts by API and status, quota usage % for read/write/list/remove categories). See the dedicated references/limits-quotas-throttling-error-codes.md for the full mathematical formulas (experience-level: 300 + concurrentUsers × N for various categories) and per-server defaults/configurable limits. ## Open Cloud contrast (for external tools) The Engine API (inside experiences) is different from the Open Cloud REST Data Stores APIs. The latter require API keys with specific scopes (universe-datastores.*), support bulk/list operations from outside Roblox, and use separate authentication. **Request budgets for Open Cloud v2 Data Stores are shared with the in-engine experience limits** (same read/write/list/remove pools). Legacy Open Cloud v1 Data Store endpoints keep their own fixed per-universe limits (see the throttling guide). Use Engine for in-experience logic; Open Cloud + Batch Processor for admin tools, migrations, or RTBF processing. Rate-limit external callers so they do not starve live servers. **Key takeaway:** Choose the variant deliberately at creation time. You cannot easily convert an OrderedDataStore into a versioned DataStore later without migration code (see best-practices-and-gotchas.md and versioning-metadata-recovery.md for migration and recovery patterns). Always test listing, versioning, and quota behavior on dedicated test experiences. -
versioning-metadata-recovery.md 7.8 KB
--- read_when: "Inspect versions, preserve metadata, or recover data after an incident" last_reviewed: 2026-07-16 --- # Versioning, metadata, and recovery Detailed coverage of the versioning system, DataStoreKeyInfo, user-defined metadata, recovery workflows, snapshots, and the Data Stores Manager. Drawn from https://create.roblox.com/docs/cloud-services/data-stores/versioning-listing-and-caching and class references for DataStore / GlobalDataStore. ## How versioning works Writes via SetAsync, UpdateAsync, and IncrementAsync on standard (non-Ordered) data stores automatically create versioned backups. - The first write to a key in a given UTC hour creates a new version snapshot. - Subsequent writes to the same key in the same UTC hour overwrite the data for that hourly version. - Versioned backups expire approximately 30 days after they are superseded by a newer write. - The *current* (latest) version of a key never expires. OrderedDataStores have **no versioning** at all. RemoveAsync on an OrderedDataStore is a true permanent delete. ## KeyInfo object (returned by many reads) When using the full DataStore path you often receive a second return value: - Version (string identifier) - CreatedTime, UpdatedTime (Unix ms since epoch) - GetUserIds() → array of numbers you associated on write - GetMetadata() → table you associated via SetOptions On UpdateAsync the transform receives the current KeyInfo as the second argument so you can read (and choose to preserve or modify) userIds and metadata. **Rule:** When calling SetAsync/UpdateAsync/IncrementAsync with metadata, you must always pass a (possibly unchanged) metadata table. Omitting it or passing nil will clear prior metadata. ## Core versioning APIs (on the standard DataStore path) - `ListVersionsAsync(key, sortDirection?, minDateMillis?, maxDateMillis?, pageSize?)` → DataStoreVersionPages - SortDirection.Ascending or Descending (default Ascending in some contexts). - Filter by time range. - Returns pages of DataStoreObjectVersionInfo (Version, CreatedTime, IsDeleted, etc.). - `GetVersionAsync(key, versionString)` → (value, KeyInfo) for that exact historical version. - `GetVersionAtTimeAsync(key, timestampMillis)` → the version that was current at (or the closest before) the given time. Extremely useful for "the player says the bug happened around 3:42 UTC on the 12th". - `RemoveVersionAsync(key, versionString)` is **deprecated** in the current Engine API. Do not use it in new code or treat it as the normal version-cleanup path. Preserve version history for recovery. Normal `RemoveAsync(key)` creates a new tombstone *current* version (GetAsync returns nil) while leaving all previous versions intact for recovery. ## Listing keys and data stores Both `DataStoreService:ListDataStoresAsync(prefix?, pageSize?, cursor?)` and `DataStore:ListKeysAsync(prefix?, pageSize?, cursor?, excludeDeleted?)` return `DataStoreListingPages`. Iterate with `GetCurrentPage()` and `AdvanceToNextPageAsync()` until `IsFinished` is true. ```lua local function listAllKeys(store, prefix) local all = {} local success, pages = pcall(function() return store:ListKeysAsync(prefix, 50) end) if not success then warn("ListKeysAsync failed:", pages) return all end while true do for _, entry in ipairs(pages:GetCurrentPage()) do table.insert(all, entry.KeyName) end if pages.IsFinished then break end local ok = pcall(function() pages:AdvanceToNextPageAsync() end) if not ok then break end end return all end ``` Use the same pattern for `DataStoreService:ListDataStoresAsync`. List operations consume `StandardList` budget. ## Practical recovery workflow (example from official docs) ```lua local maxDate = DateTime.fromUniversalTime(2020, 10, 9, 1, 42) -- time of the incident local listSuccess, pages = pcall(function() return store:ListVersionsAsync(DATA_STORE_KEY, Enum.SortDirection.Descending, nil, maxDate.UnixTimestampMillis) end) if listSuccess then local items = pages:GetCurrentPage() if #items > 0 then local closest = items[1] local success, value, info = pcall(function() return store:GetVersionAsync(DATA_STORE_KEY, closest.Version) end) if success then local setOptions = Instance.new("DataStoreSetOptions") setOptions:SetMetadata(info:GetMetadata() or {}) local restored, restoreErr = pcall(function() return store:SetAsync(DATA_STORE_KEY, value, info:GetUserIds() or {}, setOptions) end) if not restored then warn("Restore SetAsync failed:", restoreErr) end -- The restore itself creates a new current version with the old data. end end end ``` You can also do this interactively through the Data Stores Manager in Creator Hub (select key → select old version → Compare or Revert). Note that the Manager's **Revert** button reverts a key to a previous version by creating a new current version with the old data; this is equivalent to the `SetAsync` restore pattern above. The **Restore** button is for data stores marked for deletion, not for reverting key values. ## Metadata use cases - Tagging data for analytics or cleanup ("EventSummer2026", "BetaTester"). - Storing extra context that travels with the key (source of the data, schema version). - Assisting automated RTBF / right-to-be-forgotten processing (combined with the userIds array). Metadata is returned on Get, GetVersion, etc., and must be round-tripped on writes if you want to keep it. ## Snapshots (Open Cloud) Before any risky publish that changes data storage logic, take a manual snapshot of all data stores via the Snapshot Data Stores Open Cloud API (daily automated snapshots may also be available). A snapshot taken at 3:29 UTC protects all data written before that time even if your 3:30 publish immediately corrupts data for keys written in the following minutes. ## Data Stores Manager capabilities (human + permissioned ops) - Browse all data stores (filter by prefix). - Drill into a data store → list keys (prefix filter). - Inspect a key: current value, metadata, full version history, last update time, status (deleted or not). - Compare any two versions side-by-side. - Revert a key to a previous version (requires Edit Data Stores permission). - Mark data store or individual key for deletion (cooldown period; a deleted key can be restored during the cooldown by calling `UpdateAsync` from the Engine API or `UpdateDataStoreEntry` from Open Cloud). Permissions (group experiences): View Data Stores, Edit Data Stores, Delete Data Stores, plus the broad "Edit all group experiences". ## Best practices around versioning and recovery - Design keys so that a single key = a coherent self-contained object (player profile, not "gold for player X + separately inventory for player X"). This makes version restores consistent. - Use versioning instead of creating new keys for every historical save (saves storage quota and key count). - Before major refactors of your save/load code, take a snapshot. - Train your team on the Manager for quick human triage of player reports ("I lost my items at 14:20"). - Document the schema version inside metadata so that on restore you know whether the old data is still compatible with current code. - For very large experiences, combine versioning with the Batch Processor / Open Cloud for bulk recovery or inspection when the Manager becomes impractical (experiences with >100 data stores may hide some aggregate numbers). Versioning is an important recovery tool. Use `ListVersionsAsync`, `GetVersionAsync`, `GetVersionAtTimeAsync`, the Manager UI, and snapshots proactively rather than only after a disaster; restore by writing a new current version.
-
-
scripts
-
BudgetMonitor.lua 5 KB · in bundle
-
SafeDataStore.lua 14.6 KB · in bundle
-
-
tests
-
SafeDataStore.spec.lua 1.8 KB · in bundle
-
-
SKILL.md 15 KB
--- name: roblox-datastores description: "Implement or debug persistent Roblox player data, leaderboards, migrations, and deletion requests. Use for concurrent writes, session locking, recovery, caching, quotas, or choosing DataStores versus MemoryStores." last_reviewed: 2026-08-18 --- # roblox-datastores Keep persistence on the server. For concurrent writes, use a pure `UpdateAsync` transform; after a write error, reconcile the backend state before retrying or compensating. **Official sources:** - https://create.roblox.com/docs/cloud-services/data-stores - https://create.roblox.com/docs/cloud-services/data-stores-vs-memory-stores - https://create.roblox.com/docs/cloud-services/data-stores/versioning-listing-and-caching - https://create.roblox.com/docs/cloud-services/data-stores/error-codes-and-limits - https://create.roblox.com/docs/cloud-services/data-stores/best-practices - https://create.roblox.com/docs/cloud-services/data-stores/data-stores-manager - https://create.roblox.com/docs/cloud-services/data-stores/observability - Engine classes: DataStoreService, GlobalDataStore, DataStore, OrderedDataStore, DataStoreOptions, DataStoreKeyInfo, DataStoreSetOptions, etc. (full reference at https://create.roblox.com/docs/reference/engine) For temporary queues, lobbies, and caches, use MemoryStoreService. Read [types-of-datastores.md](references/types-of-datastores.md) when choosing a store. Cross-reference: - [roblox-networking/SKILL.md](../roblox-networking/SKILL.md) for server-authoritative validation before any write. - [roblox-core/SKILL.md](../roblox-core/SKILL.md) for service acquisition patterns, pcall discipline, and Luau serialization rules. - [roblox-gamepasses/SKILL.md](../roblox-gamepasses/SKILL.md) when purchases affect persistent state (re-verify ownership on load). - [roblox/SKILL.md](../roblox/SKILL.md) for the overall hub and Engine API link. ## Choose a store 1. Is the data temporary/ephemeral, high-churn, or only needed while servers are active (queues, lobbies, rate counters, short caches)? → Use **MemoryStoreService** (queues, sorted maps, etc.). DataStores are overkill and more expensive/slower. See references and official comparison. 2. Do you need sorted numeric queries for leaderboards or rankings? → Use **OrderedDataStore** (via GetOrderedDataStore). Values **must** be integers. No versioning/metadata. Special GetSortedAsync + DataStorePages iteration. Limits differ (see references/limits...). 3. Do you need versioning for recovery/audit, user-defined metadata, key listing, or the richer DataStore API (`ListKeysAsync`, `ListVersionsAsync`, `GetVersionAsync`, `GetVersionAtTimeAsync`)? → Use the full **DataStore** created via `GetDataStore` with `DataStoreOptions` (this returns the modern DataStore class). Preferred for serious player data. 4. Simple key-value persistent storage without the above needs? → Use `DataStoreService:GetDataStore()` (returns a standard DataStore) or `DataStoreService:GetGlobalDataStore()` (legacy global store, scope `"u"`). For new work, prefer a named `GetDataStore` store. **Scopes vs prefixes (modern recommendation):** For new work, prefer key prefixes (e.g. "profiles/User_1234") + ListKeysAsync filtering over legacy scopes. Scopes prepend to keys automatically. Use DataStoreOptions.AllScopes=true + empty scope string for cross-scope listing (see references). **Studio access:** If Studio needs backend APIs, use a dedicated test experience. Do not enable Studio sessions to read or write production data. ## Recommended safety workflows ### Player data profile pattern (most common) See references/best-practices-and-gotchas.md for full examples and variations (including session locking and profile patterns). High-level server flow (in a ModuleScript required by ServerScriptService scripts): 1. On PlayerAdded: - Load with pcall + GetAsync (consider UseCache=false for critical fresh read after potential previous server issues). - Merge with defaults. - Store in a server-side table or profile object (e.g. `playerData[player.UserId] = data`). - Apply any gamepass perks (cross-ref monetization skill) or other state. - Optionally fire a Remote to client for initial UI sync (never trust client data). 2. During play: - Mutate the in-memory profile. - Periodically auto-save (every 30-60s) using UpdateAsync or SetAsync with proper transform. - On important events (purchase, level up, trade), save immediately with UpdateAsync for safety. 3. On PlayerRemoving / BindToClose / character death: - Final save with pcall. - Clean up in-memory data. - For BindToClose, yield up to ~30 seconds to finish final saves (Roblox gives a bounded grace period). 4. On errors during write: - Use the verification pattern: after a failed write, immediately GetAsync with UseCache=false to see actual backend state before deciding retry/refund/rollback. Use **UpdateAsync** with a pure (non-yielding) transform function for any value that can be concurrently modified by multiple servers. It reads-then-writes atomically from the perspective of the last writer. **Serialization rules (critical):** Only nil, booleans, numbers (no inf/-inf/nan), strings (valid UTF-8), buffer, and tables of the above. No functions, no metatables on saved tables, no cycles. Use HttpService:JSONEncode on suspect data during development to preview what will actually be stored. See references/core-operations... . ### Leaderboards with OrderedDataStore Populate on relevant value changes or on save (use SetAsync or Increment). Query with GetSortedAsync(ascending, pageSize, min?, max?) → DataStorePages. Iterate pages with GetCurrentPage() + AdvanceToNextPageAsync(). For bulk reads, use `BatchGetAsync(keys)` to fetch multiple ordered entries in one request. Each key counts as one `OrderedRead` (see error-codes-and-limits for quotas; missing keys are omitted). Remember: only numbers; keys are strings; page iteration has its own limits; no versioning. Display in a ScrollingFrame or via UI on demand. Cache top-N in a standard DataStore if you want fast global access without repeated queries. See references for full page iteration example and limits differences. ### Versioning and recovery Every Set/Update/Increment on a standard DataStore (not Ordered) creates hourly-versioned backups: the first write to a key in a given UTC hour creates a new version snapshot, and subsequent writes in the same hour overwrite that hourly version. Versioned backups expire ~30 days after being superseded; the current version never expires. - ListVersionsAsync(key, sort, minDate?, maxDate?, pageSize?) - GetVersionAsync(key, version) - `RemoveVersionAsync` is **deprecated**. Do not present it as a current cleanup API; retain versions for recovery and use `ListVersionsAsync`, `GetVersionAsync`, or `GetVersionAtTimeAsync` to inspect them. - GetVersionAtTimeAsync(key, timestampMillis): find the version current at a specific past time. - To revert: read the desired version, then SetAsync the value + metadata back (this creates a new current version). Also use the daily Snapshot Data Stores Open Cloud API before risky publishes. The Data Stores Manager in Creator Hub lets you browse keys, view metadata/version history, compare versions, and revert (with proper permissions). See [references/versioning-metadata-recovery.md](references/versioning-metadata-recovery.md) for complete code samples including the "revert to time of incident" pattern. ### Caching control By default: GetAsync results are cached locally for 4 seconds. Subsequent Gets within the window return cache and do **not** count against limits. Writes update the cache immediately. For verification after writes (especially after errors), or when you suspect staleness due to other servers: ```lua local opts = Instance.new("DataStoreGetOptions") opts.UseCache = false local value, info = store:GetAsync(key, opts) ``` This always hits the backend and counts against quotas. Use sparingly but correctly. See references/versioning-metadata-recovery.md (for listing, caching, serialization) and references/best-practices-and-gotchas.md . ### Limits, quotas, throttling, and budgets There are **experience-level** limits (scale with concurrent users across the whole experience) and **per-server** limits (configurable via DataStoreService:SetRateLimitForRequestType). Experience-level pools are **shared between game servers and Open Cloud v2** Data Store traffic. Bulk external jobs can throttle live players (and vice versa). UpdateAsync counts against *both* read and write budgets. Use DataStoreService:GetRequestBudgetForRequestType(Enum.DataStoreRequestType.XXX) to check before heavy operations and wait in a loop if necessary. Queues exist (size 30); when full, you get throttle errors (301-306 range or the newer *Throttled errors). Full tables of every error code (101 KeyNameEmpty ... through all the *ExperienceThrottled and *GameServerThrottled variants), experience formulas (base 300 + concurrentUsers × N), and server-side errors are in references/limits-quotas-throttling-error-codes.md . Observability dashboard (Creator Hub → Monitoring → Data Stores) shows real-time request counts by API/status, quota usage percentages, storage bytes vs limit. Data Stores Manager shows total size vs storage limit (`500 MB + 1 MB × lifetime users`, measured as compressed latest-version size), per-DS key counts, etc. Do not pre-compress values before writing. Call SetRateLimitForRequestType early in server init (once per type) to raise limits for migration scripts or busy servers. Base + (perPlayer * numPlayers). Constraints per type are documented. ### Handle uncertain writes **Every** datastore call must be inside pcall. On failure: - Log the specific error code/message. - Retry reads with bounded exponential backoff and jitter. - Treat an invoked write that returns an error as **unknown**, not rejected: reconcile with a fresh `GetAsync` using `UseCache = false`. - Replay a write only when the caller has proved the entire operation idempotent. Never automatically replay `IncrementAsync`. - Return explicit `committed`, `rejected`, or `unknown` status so purchase/recovery code cannot mistake ambiguity for failure. Never assume a failed Set/Update/Increment means "no change occurred." `UpdateAsync` transforms can run multiple times and must not yield or produce external side effects. RemoveAsync creates a "tombstone" (Get returns nil) but older versions remain queryable for 30 days (unless explicitly removed). ### Management and monitoring - Use Data Stores Manager (Creator Hub) for inspection, key search by prefix, version compare/revert, and scheduled deletion (with cooldown). The Manager's **Revert** button can revert an individual key to a previous version. The **Restore** button is for data stores that are marked for deletion, not for reverting a key's value. Restoring a deleted key during its cooldown requires calling `UpdateAsync` from the Engine API or `UpdateDataStoreEntry` from Open Cloud. - Observability dashboard for trends and quota forecasting. - For bulk delete/migrate: Open Cloud Data Stores APIs or the official Batch Processor CLI/tool. - Set up notifications for approaching/exceeding storage limits. - Review usage regularly. See references/versioning-metadata-recovery.md (covers listing/caching) and the manager/observability pages (linked in best-practices-and-gotchas.md). ## Checklists **Before any critical write:** - [ ] Wrapped in pcall with explicit committed/rejected/unknown handling - [ ] Using UpdateAsync where races are possible, with a pure transform - [ ] Providing userIds array for RTBF/GDPR tracking where appropriate - [ ] Metadata supplied on every write (even if unchanged) when using full DataStore - [ ] Value is serializable (test with JSONEncode during dev) - [ ] Key naming consistent with prefixes for easy listing **On player join/load:** - [ ] Fresh load or cache-aware as appropriate - [ ] Merge defaults safely - [ ] Re-verify ownership on PlayerAdded (gamepasses, etc.) and re-apply any non-owned one-time consumable grants - [ ] Client sync only of non-sensitive derived state **Before publishing risky data-logic changes:** - [ ] Take a daily snapshot via Open Cloud if available - [ ] Test thoroughly on a copy experience **Ongoing:** - [ ] Monitor dashboard and manager - [ ] Clean test/temporary data - [ ] Use MemoryStores for anything that doesn't need to survive server death - [ ] Prefer fewer data stores + larger cohesive objects + key prefixes ## Common mistakes - Calling from client/LocalScript (immediate error + security hole). - Storing functions, metatables, or non-serializable data. - Using SetAsync for contended values (race condition → lost updates or duplication). - Ignoring throttle errors and hammering (leads to dropped requests and player frustration). - Hardcoding studio API access on live places. - Creating hundreds of tiny data stores instead of organizing by key/prefix. - Relying on cached Gets for post-write verification. - Forgetting that OrderedDataStore has no versioning and only integers. - Not handling the case where RemoveAsync has already been called (nil + tombstone). - Exceeding storage quota without monitoring (leads to surprise costs and potential write failures). ## Scripts folder usage The `scripts/` directory contains maturity-labeled examples: - `SafeDataStore.lua`: reviewed example with automatic read retries, explicit write states, opt-in idempotent replay, and uncached reconciliation. - `BudgetMonitor.lua`: experimental budget-inspection example. Adapt and validate them in a dedicated test experience; this directory is not a versioned production library. ## How to proceed in practice 1. Read the relevant references/ file(s) for the exact feature you are implementing. 2. Implement using the patterns and code samples here + in references. 3. Test budget behavior under load (use multiple test servers or the rate-limit setter for simulation). 4. Add observability and alerts early. 5. Profile storage growth with the Manager/Dashboard. Combine this guidance with static analysis, focused tests, Studio integration, observability, and current official sources before trusting a persistence system. For the authoritative class/property reference for any specific method, always cross-check https://create.roblox.com/docs/reference/engine (search for DataStoreService, GlobalDataStore, etc.). <!-- catalog:references:start --> ## Reference index - [best-practices-and-gotchas.md](references/best-practices-and-gotchas.md): Design player profiles, session locking, saves, or deletion workflows. - [core-operations-and-patterns.md](references/core-operations-and-patterns.md): Implement reads, writes, transforms, or error recovery. - [limits-quotas-throttling-error-codes.md](references/limits-quotas-throttling-error-codes.md): Diagnose throttling, inspect budgets, or plan request and storage usage. - [types-of-datastores.md](references/types-of-datastores.md): Choose standard, ordered, or temporary storage and organize keys. - [versioning-metadata-recovery.md](references/versioning-metadata-recovery.md): Inspect versions, preserve metadata, or recover data after an incident. <!-- catalog:references:end -->
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.