Browse Templates/Dating App

Dating App

A complete dating application database schema inspired by platforms like Tinder, Bumble, or Hinge. Includes user accounts with rich profiles (photos, interests, about me), configurable matching preferences (age range, distance, gender), swipe-based matching system with mutual match detection, and in-app messaging between matched users. Suitable for building modern dating applications or social discovery platforms.

PostgreSQL10 tablesSocial
datingmatchingprofilesswipemessagingsocial
Created with ERD Studio

About this schema

matches stores every swipe, not only the successful ones. Each row is one person's action on another, and is_mutual marks the moment the second row arrives and the pair becomes a match. Storing the passes as well is what stops the same profile being shown again, which is the other half of what a swipe means.

Messages hang off the match, not off the two users. messages.match_id means a conversation cannot exist without the mutual action that permitted it — unmatching removes the right to message by removing the parent, and no separate check is needed at send time.

Preferences and profile are deliberately different tables. profiles holds what someone is, including location_lat and location_lng; preferences holds what they are looking for — an age range, a distance limit, a gender preference. Candidate selection is the intersection of one person's preferences with another's profile, run in both directions.

Key relationships

users → profiles / preferences / photos
Three one-to-one or one-to-many satellites around the account, so the login row stays small and the profile can be rebuilt without it.
matches (swiper_id, swiped_id)
Two keys into users on one row, recording a directional action. The pair of opposite rows is what is_mutual summarises.
matches → messages
Conversations belong to the match. Removing the match removes the permission, which is why messaging needs no further authorisation check.
users ↔ users via blocks / reports
Safety is two separate records — a block is a unilateral action, a report is a request for someone else to act, so it carries a status.
users → subscriptions / boosts
Both are time-bounded grants with expires_at, so a premium feature ends by itself rather than needing a job to revoke it.

Design decisions

Every swipe is stored, including the passes

Keeping only mutual likes would make the table far smaller and break the product: without the passes there is no record of who has already been shown, so the same profiles reappear forever. The cost is the largest table in the app by a wide margin, which is why it is usually partitioned or aged out — a pass from two years ago has little value.

is_mutual is denormalised onto the row

Whether a match exists is derivable by looking for the opposite row, and doing that on every list render is a self-join on the biggest table in the database. The flag is written when the second swipe lands, making the match list a simple filtered read. Both rows must be updated together, and that write is the one place this design can go wrong.

Location is two numeric columns, not a point type

location_lat and location_lng are portable across databases and readable in any client. The honest limitation is that distance filtering on plain numbers cannot use a spatial index — a bounding box narrows it, but a real deployment moves to a geography type or a geohash column. This schema shows where the data lives, not how to make the search fast.

Blocks and reports are separate tables

A block is immediate, unilateral and needs no review. A report is a claim that someone else must assess, which is why it carries a reason, a description and a status. Merging them would give blocking a workflow it does not need, and would leave reports with no way to express that nobody has looked at them yet.

Tables in this template

usersUsers
8 cols
profilesProfiles
10 cols
photosPhotos
7 cols
preferencesPreferences
7 cols
matchesMatches
6 cols
messagesMessages
6 cols
reportsReports
7 cols
blocksBlocks
4 cols
subscriptionsSubscriptions
8 cols
boostsBoosts
6 cols

Frequently asked

How is a match created?
The second person swipes, which inserts the opposite row. At that point both rows get is_mutual set, and the pair becomes a match that messages can be attached to.
How do I avoid showing the same profile twice?
Exclude anyone already present in matches as a swiped_id for this user. That is only possible because passes are stored — recording likes alone would leave rejected profiles in the candidate pool forever.
Does unmatching delete the conversation?
If the match row is deleted, yes — messages reference it. Many apps soft-delete the match instead so the history survives a report, which is a decision to make before launch rather than after the first complaint.
Why not store distance between users?
Because it changes whenever either person moves, so it would need recomputing across every pair. Only each profile's own coordinates are stored; distance is calculated per query against the searching user's position.
What does boosts actually do?
It records a time window with a boost_type. The ranking that makes a boosted profile appear more often is application logic — the table stores only the entitlement and when it ends, the same shape as subscriptions.