Data Modeling
Model data from the access patterns: list the queries first, choose keys and partition keys that answer each in one place, and denormalize on purpose.
On this page
A team builds the reservation system from requirements and scope. They split the reservations table across 8 database partitions by a hash of the reservation id, so rows spread perfectly evenly. Then two diners book table 7 at 19:30 at the same moment. Their two new reservations hash to different partitions, each partition checks only its own rows, both find no conflict, and both commit. The table is double-booked, the one thing the requirements forbade.
The partitioning was wrong for this system because nobody first asked which queries run, and which of them must see each other's writes.
Access patterns first
Entities are the easy part: restaurants, tables, time slots, reservations, diners. Draw them in a minute. Then list every way the system reads and writes them, with a rough rate, before choosing a single key. The rates come from the estimates (20 million searches and 1 million bookings a day, so about 1,000 searches and 50 bookings a second at peak).
| Access pattern | Peak rate | Looked up by |
|---|---|---|
| Open slots at one restaurant on one date, for a party size | 1,000/s | restaurant, date |
| Book a slot, never twice | 50/s | restaurant, table, time |
| A restaurant's bookings for tonight | low | restaurant, date |
| A diner's upcoming reservations | moderate | diner, time |
| Restaurants by city and cuisine | with search | text and filters |
Read the right-hand column: almost everything starts from a restaurant, one pattern starts from a diner, and the last belongs in a search index, not this database.
A worked schema
restaurants (restaurant_id PK, name, city, cuisine, timezone)
tables (restaurant_id, table_id, seats,
PRIMARY KEY (restaurant_id, table_id))
slots (restaurant_id, slot_date, slot_time, table_id, seats,
reservation_id NULL, -- NULL means free
PRIMARY KEY (restaurant_id, slot_date, slot_time, table_id))
reservations (restaurant_id, reservation_id, table_id, slot_date, slot_time,
user_id, party_size, status, created_at,
PRIMARY KEY (restaurant_id, reservation_id))
user_reservations (user_id, slot_date, slot_time, reservation_id,
restaurant_name, -- copied on purpose
PRIMARY KEY (user_id, slot_date, slot_time, reservation_id))Why each piece is there:
- A row per slot. "Is table 7 free at 19:30?" becomes one row, and booking becomes a conditional update: set
reservation_idonly where it is stillNULL. If zero rows change, someone else won, and the API returns 409. The database does the check and the write as one step, so there is no race. - Every restaurant table starts with
restaurant_id. The slot update and the reservation insert share a partition key, so they commit in one local transaction, and an open-slots query reads one contiguous range. user_reservationsrepeats data. The diner's page needs reservations sorted by time with the restaurant's name. Keyed byuser_id, that is one range read with no join.
Size it. Slots for 50,000 restaurants × 20 tables × 8 sittings a day × 60 days ahead = 480 million rows; at about 50 bytes each, 24 GB. Reservations at 1 million a day × 365 × 200 bytes = 73 GB a year. Both fit one server, and about 1,050 operations a second at peak suits one primary with read replicas. You may never shard. Pick the partition key anyway: if you ever do, it decides which transactions stay on one machine.
Normalize, then copy on purpose
Normalized means each fact lives in one place: the restaurant's name only in restaurants. Updates are simple and nothing disagrees, but reads join. Denormalized means you copy facts next to where they are read, so reads are one lookup, and every change must reach every copy.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Normalized only | One copy, always correct. Easy to change. | The diner's page joins reservations to restaurants, and across partitions that is many lookups. | One database node, or data that changes often. |
| Copy into the read view Usual default | The diner's page is one partition read. | A renamed restaurant shows the old name until the copies are updated. | Data read far more than it changes, where brief staleness is harmless. This case. |
The rule: start normalized and denormalize one access pattern at a time, each with a named reason and a named way to keep the copy in step.
Choosing the partition key
When data outgrows one machine, the partition key decides which machine holds each row (the mechanics are in sharding and partitioning). A good key puts everything a frequent query or a transaction needs on one partition, and spreads load evenly. Those two goals pull against each other.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| restaurant_id Usual default | Booking checks, open-slot reads and tonight's list each touch one partition. | A very popular restaurant concentrates its traffic on one partition. | Most queries and every transaction start from a restaurant. This case. |
| Hash of reservation_id | Perfectly even spread. | No query starts from a reservation id except "get by id". Booking checks span partitions: the hook's double booking. | Reads are almost always by the item's own id. |
| user_id | A diner's list is local. | Two diners booking the same table land on different partitions. | As the key of the separate per-diner view only. |
| city | City searches are local. | A few big cities hold most of the load: badly uneven partitions. | Rarely. Low-cardinality keys make hot spots. |
What would change the choice: if one restaurant could take thousands of bookings a second (a stadium, say), its partition would run hot and you would split its slots by date or section. At 50 bookings a second for the whole system, that is not this problem.
How a booking flows through the model
1A diner books table 7 at 19:30 at restaurant 42. The API routes by
restaurant_id42 to one partition.2On that partition, one transaction: the slot update (only if still free) and the reservation insert. Same partition key, so no cross-machine coordination.
3It commits and the diner gets
201. The booking is now final, whatever happens next.4A
bookedevent goes on the queue. (In production, write the event to an outbox table in the booking transaction and let a relay publish it, so a crash cannot lose it.)5The view updater writes a row into
user_reservationson the diner's partition, with the restaurant name copied in. It upserts, so an event delivered twice is harmless.6The diner opens "My reservations": one range read on one partition by
user_id, already sorted by date and time.7Now the updater is down. New bookings still succeed and stay correct; only the diner's list lags. The app shows the booking from the 201 reply until the view catches up.
In the interview
Spend about 3 minutes here, as in the framework, and let the access patterns pick the store: single-key lookups suggest a key-value store, transactions and constraints suggest a relational database (see choosing a database). Here that is a relational database, because a booking is a conditional update plus an insert in one transaction. Phrases that show judgment:
- "Before picking keys, here are the five queries and how often each runs."
- "Booking must be atomic with the availability check, so both live under the same partition key."
- "The diner's list is a denormalized view, updated from events. It can lag a few seconds; the booking itself cannot."
How would you partition the reservations?
By restaurant. The booking check, the open-slot query and the restaurant's own view all start from a restaurant, so each is one partition. The cost is the diner's list, which starts from the user, so I keep a separate copy keyed by user id.
What about a hugely popular restaurant?
It's at most a few hundred slots a night, so the volume is small; the issue is contention on the same rows when bookings open. The conditional update keeps it correct, and a queue in front of that restaurant's booking page would smooth the burst.
Follow-ups you should expect:
- "Why not a secondary index on
user_id?" On one node, that is simpler and fine. On a partitioned store, a global secondary index is a separately partitioned copy. Depending on the store it updates asynchronously (the same lag, with less control) or in a cross-partition transaction (slower bookings). - "How do you handle time zones?" Store the slot in the restaurant's local date and time plus its time zone, since that is how diners and staff think about it.
Common mistakes
Slots are stored as one row per table per sitting: 50,000 restaurants, 20 tables each, 8 sittings a day, 60 days ahead, about 50 bytes a row. About how big is the table?
Why is user_id a poor partition key for the slots table?
What does copying restaurant_name into user_reservations cost?
What to practice next
See how indexes make each access pattern fast in Indexing, then practice the whole module on Design Ticket Booking, where the same no-double-booking rule shapes the data.