Instagram

Complete database schema for an Instagram-like visual social platform featuring user profiles, photo and video posts with multiple media, Instagram Stories with viewer tracking, Reels short-form video, comment threads, likes, hashtag discovery, follow relationships, close friends lists, direct messaging with threads, and highlight collections.

PostgreSQL17 tablesSocial
photosocialstoriesreelsinfluencer
Created with ERD Studio

About this schema

This schema splits an account into users and profiles. users holds what the system needs to let someone in — credentials, phone, the is_private flag; profiles holds what other people see, including the follower, following and post counts. The counts are stored rather than derived because they are read on every profile view and written comparatively rarely.

Content is not one table. posts is the grid, stories expires after a day, and reels is its own table rather than a flag on posts. They are separated because almost nothing about them is shared: a story has an expiry and a viewer list, a reel has a play count, and a post has a carousel of media and a location.

The follow graph is a single directional table with an is_pending flag. A follow of a public account is created already accepted; a follow of a private one sits pending until approved. Both are the same row, so the request and the relationship never drift apart.

Key relationships

users → profiles
One row each, split by who reads it. Sign-in touches users; every profile view touches profiles. Keeping the counters off the credentials row means a follow does not write to the row used for authentication.
posts → post_media
A carousel is many rows ordered by position, not a JSON array. Each item keeps its own width, height and duration, which is what the client needs to lay out before the file loads.
follows → users (twice)
follower_id and following_id both point at users, so the row is directional. Reading followers and reading following are the same table with the sides swapped.
stories → highlight_items
A highlight does not copy a story, it points at one. That is why a highlight survives the 24-hour expiry — expires_at governs the feed, not the row.
comments → comments
parent_id makes replies a self-reference instead of a second table. A top-level comment has it null, and a thread is one query with a recursive CTE.

Design decisions

Reels are a separate table from posts

They look similar enough to merge — both are a user's media with a caption and a like count. But a reel has a play count, a fixed vertical aspect, no carousel and no location, while a post has none of the first and all of the rest. Merging them means a table where half the columns are null depending on a type flag, and every feed query has to filter on that flag. Separate tables cost a union on the rare screen that shows both.

Counts are stored, not counted

follower_count, post_count, like_count and comment_count could all be derived. They are stored because a profile page reads four of them and writes none, and a count over a follow table with millions of rows is not a query you want on a page load. The cost is that the counter and the rows can drift, so they need a periodic reconciliation — which is cheaper than being correct on every read.

Story views are rows, not a number

story_views records who watched, not how many, because the owner is shown the list of viewers. A counter would answer the wrong question, and the table is naturally bounded — the story expires and the rows can go with it.

Pending follows live in the follows table

A request to follow a private account could be its own table, moved on approval. Keeping it as is_pending on the follow row means approving is one update rather than a delete and an insert, and a rejected request is a delete either way. It also makes 'is this person following me' a single lookup instead of checking two places.

Tables in this template

usersUsers
9 cols
profilesProfiles
11 cols
postsPosts
9 cols
post_mediaPost Media
9 cols
hashtagsHashtags
4 cols
post_hashtagsPost Hashtags
4 cols
followsFollows
5 cols
storiesStories
8 cols
story_viewsStory Views
4 cols
highlightsHighlights
6 cols
highlight_itemsHighlight Items
5 cols
commentsComments
8 cols
post_likesPost Likes
4 cols
dm_threadsDM Threads
5 cols
dm_participantsDM Participants
5 cols
direct_messagesDirect Messages
8 cols
reelsReels
10 cols

Frequently asked

Why are users and profiles two tables?
They are read by different things. Sign-in needs the credential row and nothing else; every profile view needs the display fields and the counters. Splitting them keeps a follow — which bumps follower_count — off the row that authentication reads, and keeps the hot profile row narrow.
Can I merge reels into posts?
You can, at the cost of a type column and a set of columns that are null for one kind or the other. It is worth it if your reels are just vertical posts. It stops being worth it as soon as reels get their own metrics, their own feed ranking, or their own moderation rules — which is the direction they usually go.
How do stories get deleted after 24 hours?
expires_at says when they leave the feed; nothing in the schema deletes them. Rows are usually kept for a while because highlights point at them and the owner can still see their own archive. A job clears rows that no highlight references once they are old enough.
Why is there no unique constraint shown on follows?
There should be one, on (follower_id, following_id). It is the constraint that stops a double tap creating two follows and doubling the counter. Add it before the counter is in production, because reconciling it afterwards means recounting every profile.
Where would the feed be built?
Not here. This schema stores what was posted and who follows whom; a feed is the join of those two plus a ranking, and at any size it is precomputed elsewhere. The schema supports a pull-based feed directly — posts joined to follows — which is fine until a popular account makes that join expensive.