Browse Templates/Messaging & Chat

Messaging & Chat

A comprehensive messaging and chat database schema for building applications like Slack, WhatsApp, or Discord. Supports one-on-one and group conversations with member management, real-time message delivery with read receipts, and file attachments. Includes conversation types, member roles, message status tracking, and media sharing capabilities.

PostgreSQL10 tablesSocial
messagingchatreal-timeconversationsattachments
Created with ERD Studio

About this schema

A direct message and a group use the same tables. conversations has a type column and a nullable name, and both kinds hold their participants in conversation_members — so there is no separate one-to-one path to maintain, and promoting a DM into a group is a change of type plus an inserted member.

Unread state is answered twice because it is two different questions. conversation_members.last_read_at is one timestamp per person per conversation, which is all a badge count needs. read_receipts stores a row per message per reader, which is what shows who has seen a specific message — far more rows, so it exists beside the cheap answer rather than replacing it.

Messages are never physically removed. is_deleted and is_edited are flags on the row, so a deleted message leaves its id intact for every reply_to_id, reaction and pin that points at it. Deleting the row instead would break threads and silently drop reactions that no longer have a target.

Key relationships

conversations → conversation_members → users
Membership is a row with a role, mute flag and last_read_at — per-person state that cannot live on the conversation itself.
conversations → messages → attachments
Files hang off the message, not the conversation, so an upload keeps the context of what was said when it arrived.
messages → messages (reply_to_id)
Self-reference for quoted replies. Null means a plain message; the link is what lets a client render the quoted original inline.
messages → reactions / read_receipts
Both are one row per user per message. Reactions add an emoji; receipts add a read_at, and both are deduplicated by their pair of keys.
users ↔ users via contacts / blocked_users
Two directional self-joins on the same table. A contact carries a private nickname; a block is just the pair and the moment it happened.
message_pins → conversations / messages
A pin names the conversation as well as the message, so pinning is scoped per conversation and records who did it.

Design decisions

DMs and groups share one conversations table

Separate tables would make a direct message cheaper — two user ids and no membership rows. They would also duplicate messages, attachments, reactions and pins across two paths forever, and every feature would have to be built twice. One table with a type column costs two member rows for a DM and makes everything downstream singular.

last_read_at beside read_receipts

A receipt per message per reader is the complete answer and grows as members times messages. A badge count does not need that: comparing last_read_at to the message timestamps answers it from one row. Keeping both means the cheap question stays cheap and the expensive one is still possible, with the cost that two places record reading and can disagree.

Deletion is a flag

is_deleted keeps the row so replies, reactions and pins keep a valid target, and so a moderator can still see what was removed. The consequence is that every read path must filter it, and a message that is truly gone — a legal erasure — needs the content column blanked rather than the row dropped.

last_message_at is cached on the conversation

The conversation list sorts by recent activity, and deriving that from a max over messages for every conversation on every open is the slowest query in a chat app. The column is updated on each send. It can drift if a write fails midway, which is why it is treated as a sort key rather than as the record of when the last message actually arrived.

Tables in this template

usersUsers
9 cols
conversationsConversations
8 cols
conversation_membersConversation Members
7 cols
messagesMessages
10 cols
attachmentsAttachments
7 cols
reactionsReactions
5 cols
read_receiptsRead Receipts
4 cols
contactsContacts
6 cols
blocked_usersBlocked Users
4 cols
message_pinsMessage Pins
5 cols

Frequently asked

How do I compute an unread count?
Count messages in the conversation created after that member's last_read_at. One timestamp per membership answers it without touching read_receipts, which exists for the separate question of who has seen a particular message.
Why is there no is_group boolean?
Because type already carries it and admits more than two values. A boolean would need replacing the first time channels, broadcasts or threads are added; a type column absorbs that without a migration.
How are duplicate reactions prevented?
With a unique constraint on message_id, user_id and emoji. Without it a double tap writes two rows and the count shown under the message is immediately wrong.
Does blocking someone remove the conversation?
No — blocked_users only records the pair. Whether blocked messages are hidden, rejected at send time or simply not notified is an application decision; the schema stores the fact, not the enforcement.
Where would typing indicators and presence go?
Not in these tables. users.status and last_seen_at cover coarse presence, but typing state changes every few seconds and belongs in an in-memory store — writing it to a database is a write per keystroke for data nobody needs a minute later.