YouTube

Full database schema for a YouTube-like video streaming platform covering user accounts, creator channels, video catalog with metadata, playlists, commenting system with threading, likes/dislikes, subscription management, watch history, notifications, channel monetization, ad break management, and community posts.

PostgreSQL15 tablesSocial
videostreamingcontentcreatorsocial
Created with ERD Studio

About this schema

Accounts and channels are separate here. users is who signs in and what they watched; channels is what publishes. Splitting them is what lets one account own several channels and lets watch_history and likes belong to a person while videos and monetization belong to a channel.

watch_history is the unusual table. It is not a log of views — the count for that lives on the video — but one row per viewer per video holding last_position_seconds. That is the resume feature: it is a row that gets updated as you watch rather than appended to.

Likes and dislikes share one table with an is_like flag rather than two tables or two counters. A vote is one row per user per video, so changing your mind is an update and taking it back is a delete, and the invariant that a person votes once is a unique constraint rather than application code.

Key relationships

users → channels
One account can own several channels. Everything a person does — watching, liking, subscribing — hangs off users; everything published hangs off channels.
users ↔ videos via watch_history
One row per viewer per video, updated as they watch. last_position_seconds is what resume reads, is_completed is what recommendations read.
videos → comments → comments
parent_id makes replies a self-reference. is_pinned and is_hidden sit on the comment, so a creator pinning or hiding one is an update on a single row.
channels → subscriptions
notify_all lives on the subscription, not on the user. Someone can follow twenty channels and want alerts from two.
channels → monetization
Kept apart from the channel row because it is read by few and written by a payout job. Revenue share and earnings do not belong on the row every video page loads.

Design decisions

Likes and dislikes are one table

is_like on a single row per user per video means a unique constraint on (user_id, video_id) enforces one vote. Two tables would need a cross-table check to stop someone liking and disliking at once, and two counters would drift independently. Switching a vote is an update to a boolean.

Watch history stores a position, not events

A view log would be append-only and enormous, and answering 'where did I leave off' would mean reading the last row of many. One row per viewer per video, updated in place, makes resume a lookup. The cost is that you lose per-session history — which is a different feature, and if you need it, it is its own table rather than a change to this one.

Channels carry their own counters

subscriber_count, video_count and view_count are stored on channels because every video page shows the channel's subscriber count. Deriving it would mean counting subscriptions on each page view. As with all stored counters, the underlying table remains the truth and the number needs reconciling.

Videos have a status, not a published flag

status carries upload, processing, published and private, because a video exists before it can be watched. published_at is separate so that scheduling is a timestamp rather than a state machine, and a video can be published, unlisted and re-published without losing its original date.

Tables in this template

usersUsers
8 cols
channelsChannels
13 cols
videosVideos
16 cols
categoriesCategories
3 cols
tagsTags
2 cols
video_tagsVideo Tags
4 cols
playlistsPlaylists
8 cols
playlist_itemsPlaylist Items
5 cols
commentsComments
10 cols
likesLikes
5 cols
subscriptionsSubscriptions
5 cols
watch_historyWatch History
7 cols
notificationsNotifications
7 cols
monetizationMonetization
7 cols
community_postsCommunity Posts
8 cols

Frequently asked

Why are users and channels separate tables?
Because one person can run several channels, and because the two are read by different pages. A watch page reads the channel's subscriber count; a sign-in reads the account. Merging them would force a second account for a second channel.
Does view_count come from watch_history?
It can be reconciled from it, but it is stored on the video because every listing shows it. watch_history has one row per viewer, so it counts viewers rather than views — if you need repeat views you need an event table, which is a third thing again.
How do I model shorts?
If they are just short videos, duration_seconds is enough and the feed filters on it. If they get their own feed, their own metrics or their own monetization rules, they want their own table — the same argument as separating reels from posts elsewhere.
Why is monetization its own table?
It is written by a payout process and read by almost nothing. Keeping it off channels means the row every video page loads stays narrow, and the money columns are not sitting in a table that most of the application can write to.
Where would recommendations be stored?
Not here. This schema holds the signals a recommender reads — watch_history, likes, subscriptions — but the output is computed elsewhere and cached. A recommendations table in the primary database goes stale faster than it can be queried.