Browse Templates/WhatsApp Clone

WhatsApp Clone

Complete database schema for a WhatsApp-like real-time messaging platform featuring user accounts, contact management, group messaging with member roles, private and group conversations, message delivery status tracking, emoji reactions, rich media attachments, ephemeral stories with view tracking, audio and video call logs, multi-device support, and user blocking functionality.

PostgreSQL16 tablesWeb App
messagingchatrealtimewhatsapp
Created with ERD Studio

About this schema

The identity is a phone number, and that single choice explains several tables. contacts maps an owner to another user with a private nickname, because the name you see is the one you saved, not the one they chose. devices exists because the same number signs in from a phone and a laptop at once, each with its own push token.

A group and its conversation are separate rows. groups holds the name, icon and invite link with group_members as the roster; conversations holds the thread, with type telling a direct chat from a group one and a nullable group_id linking the two. Renaming a group therefore touches nothing that messages depend on.

message_status is one row per message per recipient, which is how the tick marks work. Delivered and read are different timestamps on that row rather than two flags on the message, because in a group of fifty the message was delivered to some people and read by fewer — a single column on the message could not hold either answer.

Key relationships

conversations → messages → message_status
The thread, the message, and one delivery record per recipient. Receipts multiply by participants, which is why they are their own table.
groups → group_members / conversations
The roster and the thread are separate. is_admin on the membership scopes moderation to that group alone.
conversations → conversation_participants
Per-person state — is_muted and last_read_at — that cannot live on the conversation because every member has a different answer.
messages → messages (reply_to_message_id)
Self-reference for quoted replies, with is_forwarded as a separate flag because forwarding is not quoting.
stories → story_views
Ephemeral posts with expires_at, and a row per viewer so the author sees exactly who looked before it disappears.
call_logs → call_participants
A call is not a two-person fact: the log holds the attempt, and each participant has their own status and join time.
users → devices
Multi-device is a row per installation with its own push token, so a message reaches every place the account is signed in.

Design decisions

Receipts are rows, not columns on the message

is_delivered and is_read on messages would be free to store and wrong for every group. Status is per recipient, so it belongs on a table keyed by message and user. The cost is the fastest-growing table in the product — messages times participants — which is why receipts are usually aged out long before messages are.

conversations.last_message_id is cached

The chat list shows the latest message for every thread, and deriving that is a top-one lookup per conversation on every open. Pointing at the message directly makes the list a join instead of many subqueries. It must be updated on each send and on deletion of the latest message, which is the write that keeps this column honest.

Deletion is a flag on the message

is_deleted keeps the row so replies that quote it, reactions on it and receipts for it still have a target. It also preserves the position in the thread, which is what lets a client show that something was removed rather than silently closing the gap. True erasure means blanking the body and removing the media row, not deleting the message.

Stories carry expires_at rather than being deleted on time

A scheduled job that deletes expired stories would make visibility depend on the job having run. Storing the expiry means every read filters on it and a story is invisible the instant it lapses, whether or not cleanup has happened yet. The actual deletion then becomes a storage task with no correctness pressure on it.

Tables in this template

usersUsers
10 cols
contactsContacts
5 cols
groupsGroups
8 cols
group_membersGroup Members
5 cols
conversationsConversations
6 cols
conversation_participantsConversation Participants
6 cols
messagesMessages
9 cols
message_statusMessage Status
6 cols
message_reactionsMessage Reactions
5 cols
message_mediaMessage Media
9 cols
storiesStories
8 cols
story_viewsStory Views
4 cols
blocked_usersBlocked Users
4 cols
call_logsCall Logs
8 cols
call_participantsCall Participants
5 cols
devicesDevices
7 cols

Frequently asked

Why are groups and conversations separate tables?
Because a direct chat has a conversation but no group. Folding them together would leave name, icon and invite link null on every one-to-one thread, and group membership would have to be modelled twice.
How do the two tick marks work?
message_status holds delivered_at and read_at per recipient. One tick is a row that exists, two are delivered_at being set, and blue is read_at — in a group the sender sees the aggregate across all those rows.
Where is the message content encrypted?
Nowhere in this schema — body is a plain column. End-to-end encryption happens before the message reaches the server, which changes what the column contains but not the shape of the table.
Why does contacts store a nickname?
Because identity is a phone number and the display name is whatever the other person set. The nickname is the saved name in your phone book, which is what should appear in your chat list regardless of what they call themselves.
Can one account be signed in on several devices?
Yes — devices holds a row per installation with its own push token and last_active_at. Message delivery is per account, so the same message is pushed to every registered device.