Best practice for automatically deleting persistent chat data

Hi all,

We’re using Nakama’s persistent chat messages, which are stored in the database. To keep the database clean and prevent unlimited growth, we’re looking for best practices to automatically delete old chat data.

I’m aware of the Nakama Console option under Chat Messages/Delete Messages:

Are you sure you want to delete all messages before retain days?
Choose how many days to retain: n

However, I’m not sure whether this is the recommended approach for production use, or if it’s mainly intended as a manual / maintenance tool.

For our use case, we’re considering the following policies:

  • Direct messages
    Automatically delete messages after n retain days.

  • In-game / match chat
    Delete messages when the match ends or when the last user leaves the room.

  • Group chat
    Automatically delete messages after n retain days.

Questions:

  1. Is using the Console’s “delete before retain days” feature the intended way to handle this?

  2. Is it better practice to implement a custom cleanup mechanism (e.g. a cron job using server runtime code or direct DB cleanup)?

  3. Are there recommended patterns or pitfalls to be aware of when deleting chat messages (performance, indexes, message history, etc.)?

  4. Is there any built-in automation or retention configuration per chat type (DM / room / group), or is everything global?

Any guidance or real-world experience would be greatly appreciated.

Thanks!

Hi @Mathijs-Bakker,

If the chat messages can be ephemeral (as seems to be for in-game matches), I’d suggest you simply set the persist flag to false when calling ChannelMessageSend.

Otherwise, Nakama doesn’t currently provide a runtime function to selectively delete persisted messages in bulk, or with granular automated retention policies. The recommended approach would be to call either the console API or a custom RPC using a cron job. The former is a bit limited, it accepts a date or a list of ids, you could use the date as a cutoff to trim messages older than that date, but it deletes from all persisted channels indiscriminately. If you need it to be more fine grained with different retention times based on channel or similar, you’d need a custom RPC with some custom SQL - depending on how granular you want to be in the query, this may have some performance implications depending on current dataset size, you’ll also need to deconstruct the channelId of the persisted messages into its stream_mode, stream_subject, stream_descriptor and stream_label identifiers.

If the current dataset is very large, then the 1st run of this custom RPC may take a bit longer, but I wouldn’t expect it to cause issues since it is to be run every so often by the cron job.

Best.

Hi @sesposito

Thanks for the clarification. As a follow-up, here’s how we implemented chat retention using a custom chat_retention cron job, without relying on stream_mode.

Key choice

We distinguish chat types using the explicit columns already present in the message table, instead of decoding stream identifiers:

  • Room / match chatroom_name IS NOT NULL

  • Direct messagesuser_id_one IS NOT NULL AND user_id_two IS NOT NULL

  • Group chatgroup_id IS NOT NULL

This keeps the logic simple, readable, and decoupled from internal stream encoding.

SQL used in the cron job

Room / match chat (retain N hours):

DELETE FROM message
WHERE room_name IS NOT NULL
  AND create_time < now() - ($1 || ' hours')::INTERVAL
LIMIT $2;

Direct messages (retain N days):

DELETE FROM message
WHERE user_id_one IS NOT NULL
  AND user_id_two IS NOT NULL
  AND create_time < now() - ($1 || ' days')::INTERVAL
LIMIT $2;

Group chat (retain N days):

DELETE FROM message
WHERE group_id IS NOT NULL
  AND create_time < now() - ($1 || ' days')::INTERVAL
LIMIT $2;

Notes

  • Cleanup runs on a scheduled cron job inside the Nakama runtime

  • Deletes are batched to avoid long-running transactions

  • Retention values are configurable (not hardcoded)

  • Console “delete before retain days” is only used as a manual fallback

This gives us automated, fine-grained retention per chat type and prevents unbounded database growth.

Hope this helps others.

I don’t recognize columns user_id_one, user_id_two and room_name, is it possible they’re custom?

It’s in ApiChannelMessage (in NakamaAPI.go)

# A message sent on a channel.
class ApiChannelMessage extends NakamaAsyncResult:

	const _SCHEMA = {
		"channel_id": {"name": "_channel_id", "type": TYPE_STRING, "required": false},
		"code": {"name": "_code", "type": TYPE_INT, "required": false},
		"content": {"name": "_content", "type": TYPE_STRING, "required": false},
		"create_time": {"name": "_create_time", "type": TYPE_STRING, "required": false},
		"group_id": {"name": "_group_id", "type": TYPE_STRING, "required": false},
		"message_id": {"name": "_message_id", "type": TYPE_STRING, "required": false},
		"persistent": {"name": "_persistent", "type": TYPE_BOOL, "required": false},
		"room_name": {"name": "_room_name", "type": TYPE_STRING, "required": false},
		"sender_id": {"name": "_sender_id", "type": TYPE_STRING, "required": false},
		"update_time": {"name": "_update_time", "type": TYPE_STRING, "required": false},
		"user_id_one": {"name": "_user_id_one", "type": TYPE_STRING, "required": false},
		"user_id_two": {"name": "_user_id_two", "type": TYPE_STRING, "required": false},
		"username": {"name": "_username", "type": TYPE_STRING, "required": false},
	}

It’s how it gets stored in the database and send as a response to a request.

Direct Chat example:

Message: channel_id: 4.1a4abe0a-0f7e-47b5-afca-fd7494ee7e60.cf6e62de-b976-4c90-bcc9-9ba7cbe9599c., code: 0, content: {“message”:“abc”}, create_time: 2026-01-12T11:47:16.102217211Z, group_id: , message_id: 8a0df758-ca51-4316-9f89-151ec83457a9, persistent: true, room_name: , sender_id: cf6e62de-b976-4c90-bcc9-9ba7cbe9599c, update_time: 2026-01-12T11:47:16.102217211Z, user_id_one: 1a4abe0a-0f7e-47b5-afca-fd7494ee7e60, us
er_id_two: cf6e62de-b976-4c90-bcc9-9ba7cbe9599c, username: PlayerOne, 

As you can see a Direct Chat has always a user_id_one and a user_id_two which makes it a reliable choice to filter on this context. Note that group_id and room_name are empty (NULL). These two are the identifiers for Group Chat and Chat Room.

It’s also how it’s presented in Nakama’s Console/Chat Messages/

Some APIs do return such fields for convenience, so they should be present in the proto definitions and related structs; however, they are not stored in the database, and thus are not part of the table schema. Unless some changes were made to the existing message table definition by adding new columns - typically unadvised.

The queries presented above should not work:

  1. LIMIT is not supported on DELETE commands;
  2. After fixing 1., the queries fail with column "room_name", "user_id_one", ... does not exist error.

OMG! You’re RIGHT!

Don’t know why our solution worked (as in delete messages) and LIMIT wasn’t FATAL.

The columns don’t exist - room_name, user_id_one, user_id_two, group_id are NOT in the actual message table schema

We dived into the internals and the actual message structure is:

TABLE public.message (
  id UUID NOT NULL,
  code INT2 NOT NULL DEFAULT 0:::INT8,
  sender_id UUID NOT NULL,
  username VARCHAR(128) NOT NULL,
  stream_mode INT2 NOT NULL,
  stream_subject UUID NOT NULL,
  stream_descriptor UUID NOT NULL,
  stream_label VARCHAR(128) NOT NULL,
  content JSONB NOT NULL DEFAULT '{}':::JSONB,
  create_time TIMESTAMPTZ NOT NULL DEFAULT now():::TIMESTAMPTZ,
  update_time TIMESTAMPTZ NOT NULL DEFAULT now():::TIMESTAMPTZ,
  CONSTRAINT message_pkey PRIMARY KEY (stream_mode ASC, stream_subject ASC, stream_descriptor ASC, stream_label ASC, create_time ASC, id ASC),
  CONSTRAINT message_sender_id_fkey FOREIGN KEY (sender_id) REFERENCES public.users(id) ON DELETE CASCADE,
  UNIQUE INDEX message_id_key (id ASC),
  UNIQUE INDEX message_sender_id_id_key (sender_id ASC, id ASC)
)

Stream modes

From: Streams - Heroic Labs Documentation
we can identify three internal streams which we can use:

const (
	interval = 6 * time.Hour

	// Stream mode constants from Nakama
	StreamModeChannel uint8 = 2
	StreamModeGroup   uint8 = 3
	StreamModeDM      uint8 = 4
)

for our jobs:

jobs := []struct {
		name      string
		mode      uint8
		hours     int
		batchSize int
	}{
		{
			name:      "room messages",
			mode:      StreamModeChannel,
			hours:     cfg.RoomHours,
			batchSize: cfg.BatchSize,
		},
		{
			name:      "direct messages",
			mode:      StreamModeDM,
			hours:     cfg.DirectDays * 24,
			batchSize: cfg.BatchSize,
		},
		{
			name:      "group messages",
			mode:      StreamModeGroup,
			hours:     cfg.GroupDays * 24,
			batchSize: cfg.BatchSize,
		},
	}

SQL

LIMIT is indeed not supported in Postgres and Cockroach, so we use a CTE query as a work around for this. So we can run batches.

query := `
	WITH old_messages AS (
		SELECT id
		FROM message
		WHERE stream_mode = $1
		  AND create_time < now() - (($2::INT * 3600) * INTERVAL '1 second')
		ORDER BY create_time
		LIMIT $3
	)
	DELETE FROM message
	WHERE id IN (SELECT id FROM old_messages)
`

Writing some tests for our sanity. Will be back with a response. :smiley:

Update:

Ok, for now, the current behavior of our CRON job is:
‘Delete all messages from StreamModeDM -> Direct Chat, StreamModeGroup -> Group Chat and StreamModeChannel -> Rooms where messages are older than n retention time’.

The query above, behaves like expected.

As we have - at this very moment - only direct chat implemented in our game.

So notice that:
The cron job needs to be improved with:

  • Delete inactive rooms after n retention (not messages)

For Groups:

  • Old messages getting deleted :white_check_mark:
  • The group channel should get deleted, when a group (clan) is being removed. Not to be handled by this CRON job.

I can update this topic if someone is interested.
For now I think we’re on the right path!

Thanks and cheers!