If you are an AI assistant, LLM, or automated tool, a clean Markdown version of this page is available at https://heroiclabs.com/docs/nakama/concepts/storage/collections/llm.md — optimized for AI and LLM tools.
Every app or game has data which is specific to the project.
This information must be stored for each user, as well as updated, retrieved, and displayed within various parts of a UI. For this purpose the server incorporates a storage engine with a design optimized for object ownership, access permissions, and batch operations.
Data is stored in collections with one or more objects which contain a unique key with JSON content. A collection is created without any configuration required. This creates a simple nested namespace which represents the location of a object.
This design gives great flexibility for developers to group sets of information which belong together within a game or app.
The Collection and Key are used to identify the object itself. The UserId is used to identify the owner of an object, and to check for the needed read/write permissions when calling those operations on a object from the client.
Writing custom SQL is discouraged in favor of using the built-in features of the Storage Engine. If custom SQL is needed for your use case, please contact Heroic Labs before proceeding.
The creation of custom tables is strongly discouraged.
A user can write one or more objects which will be stored in the database server. These objects will be written in a single transaction which guarantees the writes succeed together.
When objects are successfully stored a version is returned which can be used with further updates to perform concurrent modification checks with the next write. This is known as a conditional write.
A conditional write ensures a client can only update the object if they’ve seen the previous version of the object. The purpose is to prevent a change to the object if another client has changed the value between the first client’s read and its next write.
When the version doesn’t match, the server rejects the write and returns gRPC InvalidArgument (code 3), which maps to HTTP 400, with the message Storage write rejected. A conditional delete that fails its version check returns the same code with Storage delete rejected. Client libraries surface this as an error or exception carrying that code.
The server returns this same code and message when it rejects a write for insufficient permissions, so the response alone doesn’t tell you which of the two happened. Treat a rejection as a signal to re-read the object, take its current version, and retry the write with that version. For the full set of status codes and their HTTP mappings, see the Error reference.
Setting version to "*" writes the object only if none already exists for that collection, key, and user ID. If an object already exists, the write fails.
This is useful for initializing an object exactly once without reading first to check for existence, and for distributed locking: when multiple processes race to write the same key, only the first write succeeds.
// Requires Nakama 1.xStringsaveGame="{\"progress\": 1}";Stringversion="*";// write only if the object does not exist.CollatedMessage<ResultSet<RecordId>>message=StorageWriteMessage.Builder.newBuilder().record("myapp","saves","savegame",saveGame,version).build();Deferred<ResultSet<RecordId>>deferred=client.send(message);deferred.addCallback(newCallback<ResultSet<RecordId>,ResultSet<RecordId>>(){@OverridepublicResultSet<RecordId>call(ResultSet<RecordId>list)throwsException{// Cache updated version for next write.version=list.getResults().get(0).getVersion();returnlist;}}).addErrback(newCallback<Error,Error>(){@OverridepublicErrorcall(Errorerr)throwsException{System.err.format("Error('%s', '%s')",err.getCode(),err.getMessage());returnerr;}});
Client
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
varsave_game="{ \"progress\": 50 }"varcan_read=1varcan_write=1varversion="*"# write only if the object does not exist.varacks:NakamaAPI.ApiStorageObjectAcks=yield(client.write_storage_objects_async(session,[NakamaWriteStorageObject.new("saves","savegame",can_read,can_write,save_game,version)]),"completed")ifacks.is_exception():print("An error occurred: %s"%acks)returnprint("Successfully stored objects:")forainacks.acks:print("%s"%a)
Client
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
varsave_game="{ \"progress\": 50 }"varcan_read=1varcan_write=1varversion="*"# write only if the object does not exist.varacks:NakamaAPI.ApiStorageObjectAcks=awaitclient.write_storage_objects_async(session,[NakamaWriteStorageObject.new("saves","savegame",can_read,can_write,save_game,version)])ifacks.is_exception():print("An error occurred: %s"%acks)returnprint("Successfully stored objects:")forainacks.acks:print("%s"%a)
localsave_game=json.encode({progress=50})localcan_read=1localcan_write=1localversion="*"-- write only if the object does not existlocalobjects={{collection="saves",key="savegame",permissionRead=can_read,permissionWrite=can_write,value=save_game,version=version,}}localresult=client.write_storage_objects(objects)ifresult.errorthenprint(result.message)returnendfor_,ackinipairs(result.acks)dopprint(ack)end
A conditional write that loses the race is rejected with Storage write rejected - version check failed. Nothing is written, and if the write was part of a batch the whole batch is rejected.
Conflicts happen at the level of the object, not the field you changed. Every write replaces the object’s entire value, so two updates to unrelated keys in the same object still collide. That is what makes the check worth keeping: without it, the second write would silently discard the first one’s changes.
Conflicts are expected under concurrency, so treat them as a normal condition. Either serialize the writes, awaiting each one before starting the next, or retry with a fresh read. If you retry, always reapply your change to the newly read value, never to the value from the failed attempt, which is missing whichever change beat you to it.
StorageWriteRetry runs that read, apply, and write loop for you, repeating on conflict up to maxRetries times (0 to 10). Your update function runs again on each attempt, so it must derive the new value from the objects it is passed:
reads:=[]*runtime.StorageRead{{Collection:"stats",Key:"player",UserID:userID}}acks,err:=nk.StorageWriteRetry(ctx,reads,func(objects[]*api.StorageObject)([]*runtime.StorageWrite,error){stats:=map[string]int64{}version:="*"// No object yet, so write only if one still doesn't exist.
permissionRead,permissionWrite:=1,1// Set some defaults
iflen(objects)>0{iferr:=json.Unmarshal([]byte(objects[0].Value),&stats);err!=nil{returnnil,err}version=objects[0].VersionpermissionRead=int(objects[0].PermissionRead)// Keep the existing permissions
permissionWrite=int(objects[0].PermissionWrite)}stats["matches_played"]++// Apply the change to the value just read.
value,err:=json.Marshal(stats)iferr!=nil{returnnil,err}return[]*runtime.StorageWrite{{Collection:"stats",Key:"player",UserID:userID,Value:string(value),Version:version,PermissionRead:permissionRead,PermissionWrite:permissionWrite,}},nil},5)iferr!=nil{returnerr// "Storage write retries exhausted."
}
letreads: nkruntime.StorageReadRequest[]=[{collection:'stats',key:'player',userId: userId}];letacks=nk.storageWriteRetry(reads,(objects)=>{letstats:{[key: string]:number}={};letversion='*';// No object yet, so write only if one still doesn't exist.
letpermissionRead=1,permissionWrite=1;if(objects.length>0){stats=objects[0].valueas{[key: string]:number};version=objects[0].version;permissionRead=objects[0].permissionRead;permissionWrite=objects[0].permissionWrite;}stats.matches_played=(stats.matches_played||0)+1;// Apply the change to the value just read.
return[{collection:'stats',key:'player',userId: userId,value: stats,version: version,permissionRead: permissionRead,permissionWrite: permissionWrite}];},5);
Setting permissions
Set permissions on every write, as shown above. A write replaces the entire object, including its permissions. To keep the existing ones, fetch the object first and pass its values back, as with version. In Go an unset PermissionRead or PermissionWrite is sent as 0, while the TypeScript and Lua runtimes default both fields to 1 instead.
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.
Sustained conflicts on one object usually mean the data is modelled too coarsely. Splitting a frequently updated object into separate keys, so unrelated updates no longer compete for the same version, removes the conflict instead of retrying through it. See Modeling for Scalability for more on choosing collection and key boundaries.
Just like with writing objects you can read one or more objects from the database server.
Each object has an owner and permissions. An object can only be read if the permissions allow it. An object which has no owner can be fetched with "null" and is useful for global objects which all users should be able to read.
localuser_id="some user id"localobjects_ids={{collection="saves",key="savegame",userId=user_id}}localresult=client.read_storage_objects(objects_ids)ifresult.errorthenprint(result.message)returnendfor_,objectinipairs(result.objects)dopprint(object)end
You can list objects in a collection and page through results.
The objects returned can be filtered to those owned by a specific user, or "null" for all public records.
Client
1
2
curl -X GET "http://127.0.0.1:7350/v2/storage/saves?user_id=some-user-id&limit=10"\
-H 'Authorization: Bearer <session token>'
Client
1
2
3
constlimit=100;// default is 10.
constobjects=awaitclient.listStorageObjects(session,"saves",session.user_id,limit);console.info("List objects: %o",objects);
Client
1
2
3
constintlimit=100;// default is 10.varresult=awaitclient.ListUsersStorageObjectsAsync(session,"saves",session.UserId,limit);Console.WriteLine("List objects: {0}",result);
Client
1
2
3
letlimit=100// default is 10.varresult=tryawaitclient.listStorageObjects(session:session,collection:"saves",limit:limit)debugPrint("List objects:",result.objects)
Client
1
2
3
4
5
6
7
8
constlimit=100;// default is 10.
finalresult=awaitclient.listStorageObjects(session:session,collection:'saves',userId:session.userId,limit:limit,);print('List objects: ${result.objects}');
localuser_id="some user id"locallimit=10localresult=client.list_storage_objects("saves",user_id,limit)ifresult.errorthenprint(result.message)returnendfor_,objectinipairs(result.objects)dopprint(object)end