Slack

Complete database schema for a Slack-like team communication platform featuring workspaces, channels, direct messages, threads, emoji reactions, file attachments, apps and integrations, user groups, and notification preferences.

PostgreSQL14 tablesWeb App
messagingcollaborationteamreal-timeworkspace
Created with ERD Studio

About this schema

Identity here is per workspace, not per account. users holds the login; workspace_members holds who you are inside one workspace — display_name_override and title live there, so the same person can be 'Kim' in one team and 'Dr. Kim, Platform' in another without a second account.

A thread is not a separate thing. messages.thread_id points at another message in the same table, so a reply is a message whose parent happens to be set. reply_count sits on the parent because a channel view needs to say '12 replies' without counting them.

Read state is a column on membership, not a table of receipts. channel_members.last_read_at and dm_members.last_read_at each hold one timestamp, which is all the unread badge needs — and it means marking a channel read is one update rather than a row per message.

Key relationships

users → workspace_members
One row per workspace someone belongs to. The display name and title hang off this row, so identity is scoped to the team rather than to the account.
messages → messages
thread_id is a self-reference. A top-level message has it null; a reply points at the message that started the thread, not at the message above it.
channels → channel_members
last_read_at per member is what the unread badge reads. Joining, leaving and marking read are all writes to this one row.
user_groups → user_group_members
Mentionable groups like @design. added_by is recorded because groups are edited by several people and someone always asks who added whom.
apps → app_installations
The app is the catalogue entry; the installation is one workspace's grant, with its own token and scopes. Uninstalling is deleting the installation, not the app.

Design decisions

Threads live in the messages table

A reply has everything a message has — an author, reactions, attachments, edits — so a separate table would duplicate all of it and then need a union on every channel read. One nullable thread_id costs a column and makes 'show this thread' an index lookup. The price is that queries must remember to filter thread_id is null for the channel view, which is exactly the bug people hit once and then never again.

Reactions carry the workspace

message_reactions.workspace_id is redundant — it can be reached through the message and its channel. It is stored because reactions are the highest-volume table here and every workspace-scoped count or cleanup would otherwise join twice to find out where a row belongs. This is the kind of denormalisation worth making deliberately and writing down, because the copy can drift if a channel ever moves.

Read state is one timestamp, not read receipts

Storing a row per user per message answers 'who has read this' and costs a row per pair, which on a busy channel is the largest table in the system. A single last_read_at answers 'how many are unread', which is the question the interface actually asks. Slack-style products that later add receipts add them for DMs only, where the volume is bounded.

Workspace identity overrides the account

display_name_override and title on workspace_members exist because the same account appears in several workspaces and should not have to pick one name. Putting them on users would make a client's workspace see your employer's title. The cost is that rendering a message joins to membership, not just to users.

Tables in this template

usersUsers
10 cols
workspacesWorkspaces
9 cols
workspace_membersWorkspace Members
8 cols
channelsChannels
11 cols
channel_membersChannel Members
7 cols
messagesMessages
11 cols
message_reactionsMessage Reactions
7 cols
message_attachmentsMessage Attachments
9 cols
direct_messagesDirect Messages
7 cols
dm_membersDM Members
6 cols
user_groupsUser Groups
8 cols
user_group_membersUser Group Members
6 cols
appsApps
9 cols
app_installationsApp Installations
8 cols

Frequently asked

Why are threads not their own table?
Because a reply is a message in every respect — author, reactions, attachments, editing. A separate table duplicates those columns and forces a union on every channel read. thread_id as a self-reference costs one nullable column instead.
Why does message_reactions store workspace_id?
To avoid joining through the message and channel on the highest-volume table in the schema. It is a deliberate copy, and like all copies it can drift — if a channel can move between workspaces, that move has to update reactions too.
How do unread counts work?
channel_members.last_read_at against message timestamps. One row per membership rather than one per user per message, which is what keeps the table small enough to be useful.
Where would message search live?
Not in this schema. A LIKE over messages stops working early; search belongs in an index built outside the database, with these rows as the source. The schema is shaped to feed one, not to be one.
Can a channel belong to two workspaces?
Not as modelled — channels.workspace_id is a single column, and Slack Connect-style shared channels need a junction instead. Adding one later also means revisiting the denormalised workspace_id on reactions.