Airbnb

Full database schema for an Airbnb-like short-term rental marketplace featuring host and guest profiles, property listings with amenities and photos, availability calendars, dynamic pricing, booking lifecycle management, bidirectional reviews, messaging, wish lists, and host payout tracking.

PostgreSQL15 tablesWeb App
rentalmarketplacebookinghospitality
Created with ERD Studio

About this schema

This schema models a two-sided rental marketplace: the same person can list a property one week and book someone else's the next. That is why there is no separate hosts table — host_profiles hangs off users and holds only what is true of someone acting as a host, such as superhost status and response rate.

The booking side is built around the idea that a price is a property of a date, not of a listing. listings carries a base_price, but availability_calendar overrides it per night, which is what lets a weekend or a holiday cost more without duplicating the listing.

Money is recorded twice on purpose. bookings stores what the guest paid, broken into base_total, cleaning_fee and service_fee; payouts stores what the host received. They are different numbers because the platform keeps a cut, and keeping both means a payout can be reconciled against the booking that produced it.

Key relationships

users → host_profiles
One row per user who hosts, not a separate account. A guest who starts hosting gains a profile without changing identity, and reviews written before that still point at the same user.
listings → availability_calendar
One row per listing per date. It carries status, price and min_nights, so blocking a night and repricing it are the same operation.
bookings → reviews
A review hangs off a booking, not off a listing, so only someone who actually stayed can write one. The type column separates the guest's review from the host's.
reviews → users (twice)
reviewer_id and reviewee_id both point at users. One booking produces two rows going opposite directions rather than one row with two rating columns.
listings ↔ amenities
Many-to-many through listing_amenities. Amenities are a shared master list so that filtering by 'has wifi' is an index lookup rather than a text search.

Design decisions

Reviews are two rows, not one

A stay produces a guest review and a host review, and they are written at different times, become visible under different rules, and can be reported separately. Holding both in one row means every query has to know which half it is reading, and a single row cannot be published twice. Two rows with a type column costs one extra join and makes 'show me everything this user wrote' a plain filter.

Availability and price live on the same row

A night that is blocked has no price, and a night that costs more is usually a night that is in demand. Splitting them into two tables means two writes to change one night and a join to read it. One row per listing per date keeps the calendar a single scan, and the table stays small because only dates that differ from the default need a row.

Photos are ordered by a column, not by insert order

listing_photos.position exists because the cover photo is a product decision, not a timestamp. Relying on created_at means reordering requires rewriting rows, and any change to upload flow silently reshuffles the gallery.

Conversations point at both a listing and a booking

A message thread often starts before a booking exists — someone asking whether the place allows dogs. listing_id is set from the beginning, booking_id is filled in if the enquiry turns into a stay. Requiring a booking up front would mean either blocking pre-booking questions or creating placeholder bookings.

Tables in this template

usersUsers
10 cols
host_profilesHost Profiles
9 cols
listingsListings
19 cols
listing_photosListing Photos
6 cols
amenitiesAmenities
5 cols
listing_amenitiesListing Amenities
4 cols
availability_calendarAvailability Calendar
8 cols
bookingsBookings
14 cols
reviewsReviews
11 cols
conversationsConversations
5 cols
conversation_participantsConversation Participants
5 cols
messagesMessages
6 cols
wish_listsWish Lists
6 cols
wish_list_itemsWish List Items
5 cols
payoutsPayouts
8 cols

Frequently asked

Why is there no separate table for hosts?
Because hosting is a role, not an identity. The same account books and lists, and splitting them would duplicate email, phone and verification, then force every message and review to check two tables to find a name. host_profiles holds only the fields that exist while acting as a host.
Where does the nightly price come from?
listings.base_price is the default. availability_calendar.price overrides it for a given date when set. A booking stores its own base_total so that a later price change does not rewrite history.
Can I drop the availability_calendar table?
Only if every night costs the same and you never block dates individually. Once either is untrue you end up encoding date ranges into the bookings table, which makes 'is this night free' a range-overlap query instead of a lookup.
Why does bookings store nights and total when they can be computed?
Because they are computed from values that change. Nights follows from check_in and check_out and is safe either way, but total depends on fees and tax rules that get edited. Storing what was actually charged keeps an old booking readable after the pricing rules move on.
How would I add instant booking versus request-to-book?
It is a property of the listing, so add a column there rather than a status on bookings. The booking status column already carries the request lifecycle; instant booking simply skips the pending state.