Browse Templates/Twitter / X

Twitter / X

Complete database schema for a Twitter/X-like microblogging platform covering user accounts, tweet content, media attachments, hashtags, follow relationships, likes, retweets, notifications, direct messaging, and trending topics.

PostgreSQL17 tablesSocial
socialmicrobloggingreal-timefeednotifications
Created with ERD Studio

About this schema

Three different things can point at a tweet, and this schema keeps them apart. A reply is reply_to_id on the tweet itself. A quote is quote_tweet_id on the tweet itself. A plain retweet is a row in retweets, because it creates no new text and should not be a tweet with a null body.

That split is the whole design. Replies and quotes are tweets — they have content, they can be replied to, they show up in the author's timeline. A retweet is an endorsement, so it is closer to a like than to a post, and retweets.comment exists only to let a quote be recorded there too if you prefer that shape.

Everything read on a timeline is denormalized onto the tweet: retweet_count, like_count, reply_count, view_count. The tables that produce those numbers are still there, so the counters are a cache of a truth that can be recomputed, not the truth itself.

Key relationships

tweets → tweets (twice)
reply_to_id and quote_tweet_id are separate self-references. Both null means an original tweet, and the two are never both set.
tweets → retweets
A retweet is a row here, not a tweet. That keeps 'show this user's tweets' from needing to exclude bodyless rows, and makes un-retweeting a delete.
hashtags → trending_topics
Trending is stored per region with started_at and ended_at, so it is a history rather than a current-state table. Yesterday's trends can still be answered.
notifications → users (twice)
recipient_id is who sees it, actor_id is who caused it. Keeping both means a notification can be rendered without re-deriving who did what.
blocks → users (twice)
Directional: blocker_id and blocked_id. A block is not mutual, and the direction decides whose timeline hides what.

Design decisions

Retweets are a table, replies and quotes are columns

A reply and a quote both carry text, so they need everything a tweet has — media, hashtags, their own replies. Making them tweets with a pointer costs one nullable column each. A plain retweet carries nothing; making it a tweet would mean a row with null content that every timeline query has to special-case, and un-retweeting would be a delete of something that looked like a post. As a separate table it is an insert and a delete, like a like.

Tweets are soft-deleted

deleted_at rather than a delete, because a tweet is referenced by replies, quotes, bookmarks and notifications. Removing the row would either cascade and take the replies with it or leave dangling pointers. Soft delete lets the thread survive with a 'this tweet was deleted' gap, which is what people expect to see.

Trending keeps its history

trending_topics has started_at and ended_at rather than being truncated and rewritten. A trending list is interesting after the fact — it is how you answer what was happening at a given hour — and rewriting the table throws that away every cycle. rank is stored because it is the output of a ranking run, not something to recompute later.

Lists record who added each member

list_members.added_by exists because a list can be collaborative. Without it, removing a contributor leaves no way to tell which members came from them, and there is no audit for a member who does not want to be on a list.

Tables in this template

usersUsers
10 cols
tweetsTweets
12 cols
tweet_mediaTweet Media
8 cols
hashtagsHashtags
5 cols
tweet_hashtagsTweet Hashtags
4 cols
likesLikes
5 cols
followsFollows
5 cols
retweetsRetweets
5 cols
notificationsNotifications
9 cols
direct_messagesDirect Messages
8 cols
dm_messagesDM Messages
8 cols
dm_participantsDM Participants
5 cols
blocksBlocks
5 cols
bookmarksBookmarks
5 cols
listsLists
9 cols
list_membersList Members
5 cols
trending_topicsTrending Topics
8 cols

Frequently asked

Why is a retweet not just a tweet?
Because it has no content. A tweet with a null body forces every timeline query to decide whether to render it as a post or as an endorsement, and un-retweeting becomes a delete of something users think of as a post. A separate table makes it behave like a like, which is what it is.
Can reply_to_id and quote_tweet_id both be set?
They should not be — a tweet is a reply or a quote, not both. The schema does not stop it, so add a check constraint if you want that guaranteed rather than assumed.
Why store like_count when likes are a table?
A timeline renders dozens of tweets and shows a count on each. Counting the likes table per tweet on every render does not survive contact with a popular account. The table remains the source of truth, so the counter can be rebuilt; it is a cache with a reconciliation job, not a replacement.
How are blocks enforced?
Not by the schema. blocks records the direction; the application filters timelines, notifications and DMs against it. Putting it in the database would mean every read joins to blocks, which is why it is usually a cached set per user instead.
Where do threads come from?
A thread is a chain of reply_to_id back to a tweet with none, or forward from one. Reading a long thread with a recursive CTE works, but most systems also store a conversation root on the tweet so that the whole thread is one indexed lookup.