Browse Templates/Reddit Clone

Reddit Clone

Complete database schema for a Reddit-like community platform featuring user accounts, subreddit communities, post submissions, nested comment threads, karma voting on posts and comments, awards system, content moderation, user flairs, post flairs, saved content, and ban management.

PostgreSQL16 tablesSocial
socialcommunityvotingforumreddit
Created with ERD Studio

About this schema

Votes are rows, not counters. post_votes and comment_votes each store one row per voter with a vote_type, which is the only way to stop the same person voting twice and the only way to let them change their mind. The score column on posts and comments is the cached sum, kept because sorting a front page by a count that has to be recomputed from millions of rows is not a query anyone can afford.

Comments nest through parent_comment_id pointing back at comments, and carry a depth column beside it. depth is redundant — it can be derived by walking up — but it is stored so that rendering a thread can order and indent in one pass, and so a reply can be refused past a limit without a recursive read first.

Moderation is scoped to the subreddit, never to the platform. moderators, bans, post_flairs and user_flairs all key on subreddit_id, so the same account can be a moderator in one community, banned in another, and an ordinary member everywhere else. Only users.is_admin sits above that, and it is a single flag rather than a role table.

Key relationships

subreddits → posts → comments
A post belongs to exactly one community and comments belong to the post. There is no cross-posting table, so a repost is a new posts row.
comments → comments (parent_comment_id)
The self-reference is the thread. A null parent means a top-level comment; depth records how far down the row already sits.
posts ↔ users via post_votes
One row per voter per post with vote_type, so a vote can be changed or withdrawn. posts.score is the cached total of these rows.
subreddits ↔ users via subreddit_members / moderators
Two different grants on the same pair. Joining a community and being able to moderate it are separate rows, so neither implies the other.
post_awards → posts / awards / users
Three keys on one row: what was given, to which post, by whom. The giver is kept because an award is a public act, not an anonymous counter.
reports → target_type + target_id
A deliberate polymorphic pair so one queue holds both reported posts and reported comments — there is no foreign key here to enforce it.

Design decisions

score is cached beside the vote rows

The honest number is count(upvotes) - count(downvotes) over the votes table, and on a busy post that is a scan nobody wants on every page render. posts keeps score, upvote_count and comment_count so a listing sorts on an indexed column. The price is that a vote write must touch two places, and the counts can drift — which is why they are periodically recomputed rather than trusted forever.

Removal is a flag, not a delete

is_removed on posts and comments keeps the row in place when a moderator takes something down. Deleting it would orphan every reply beneath it and silently renumber the thread for everyone reading. Keeping the row means the children still hang off a valid parent, and the moderation log in reports still points at something that exists.

Flairs are two tables, not one

post_flairs is the set a subreddit defines; user_flairs is what a specific member wears in that subreddit. They look similar enough to merge, but one is a vocabulary and the other is an assignment — merging them would make renaming a community's flair rewrite what individual members are labelled.

bans carry both who and by whom

banned_user_id and banned_by_id sit in the same row, with expires_at nullable for a permanent ban. Storing only the banned user would make a contested ban unanswerable, and putting the expiry on the user would mean a person banned from two communities could only be banned until one date.

Tables in this template

usersUsers
12 cols
subredditsSubreddits
12 cols
subreddit_membersSubreddit Members
4 cols
postsPosts
15 cols
post_votesPost Votes
5 cols
commentsComments
10 cols
comment_votesComment Votes
5 cols
awardsAwards
6 cols
post_awardsPost Awards
5 cols
saved_postsSaved Posts
4 cols
saved_commentsSaved Comments
4 cols
post_flairsPost Flairs
5 cols
user_flairsUser Flairs
6 cols
moderatorsModerators
5 cols
reportsReports
8 cols
bansBans
7 cols

Frequently asked

Why store every vote instead of just incrementing score?
Because a counter cannot tell whether this user has already voted. Without the rows the same person can upvote repeatedly and can never switch to a downvote, and there is no way to recompute a score that has drifted.
Is a recursive query needed to render a comment thread?
Not for display. Fetching all comments for a post and sorting by created_at with the stored depth is enough to lay out the indentation. A recursive CTE is only needed when you want a single subtree without reading the whole post.
Why does reports use target_type and target_id?
So moderators get one queue instead of two. The trade is that the database cannot enforce the reference — the application has to check the target still exists, and a stale report is possible.
How are karma totals kept in sync?
users carries karma, post_karma and comment_karma as cached totals updated when votes land. They are derived figures, so a periodic recount is normal; treating them as the source of truth is what makes them wrong.
Can someone be a member of a subreddit without a row in subreddit_members?
Yes, in the sense that anyone can read a public community. The row records joining — it drives the subscribed feed and member_count, not read access, which is why is_private exists as a separate check.