Movie ticket booking is a favorite concurrency problem: two people must never end up with the same seat. The key ideas are that seat availability belongs to a show, not to the auditorium, and that booking is two-phase: lock the seats, then pay, then confirm or release.
Requirements#
Scope it to one theater and the booking flow:
- A theater has auditoriums with a grid of seats (N rows × M seats); seats are REGULAR or VIP.
- A show is a movie in an auditorium at a start time, with prices per seat type.
- A user picks a show and one or more seats.
- Seats are locked before payment; on success they become booked, on failure they're released.
- Users can see available seats for a show.
Actor: the user.
Clarifications
- One theater with a limited number of auditoriums.
- Seat types affect price.
- Failure cases: seat already locked or booked, invalid seat, payment failure.
Out of scope: UI, real payment (behind an interface), lock timeouts (see extensions).
Core entities#
The important split is between physical seats and their per-show status:
| Entity | Holds |
|---|---|
Movie |
id, name, description |
Seat |
id, seat type |
Auditorium |
id, seats |
Show |
id, movie, auditorium, time, prices, seat_status: seat_id → BookStatus, lock |
BookStatus (enum) |
AVAILABLE, LOCKED, BOOKED |
Ticket |
id, show, seats, status |
Payment (interface) |
pay(amount) -> bool |
BookingSystem |
shows; orchestrates lock → pay → confirm |
Relationships#
Most links are plain associations; per-seat status is owned by the show:
| From | To | Kind |
|---|---|---|
Auditorium |
Seat |
Composition |
Show |
Movie, Auditorium |
Association |
Show |
per-seat status | Composition: status exists only for that show |
BookingSystem |
Show |
Holds the shows |
Ticket |
Show, Seat |
Association |
The same physical seat is AVAILABLE for the 7pm show and BOOKED for the 9pm show, which is why status lives on Show.
Key flows#
Booking happens in two phases, and both run under the show's lock.
lock_seats(show, seats): under the show's lock, check that every seat is AVAILABLE; if any isn't, fail without changing anything (all-or-nothing); otherwise mark them all LOCKED.
confirm_booking(show, seats, payment):
- Compute the total from the seat types.
- Charge the payment.
- On failure, release the seats.
- On success, mark them BOOKED (under the show's lock, verifying they're still LOCKED) and issue a ticket.
Concurrency: every read-then-write of seat_status happens inside Show's methods under a per-show threading.Lock. BookingSystem never touches seat_status directly, so no check-then-act race leaks out. Locking per show (not globally) lets different shows book in parallel.
Code#
The Show class owns all seat-status changes; BookingSystem only orchestrates:
import threading
import uuid
from abc import ABC, abstractmethod
from datetime import datetime
from enum import Enum
from typing import Dict, List, Optional
class BookStatus(Enum):
AVAILABLE = "AVAILABLE"
LOCKED = "LOCKED"
BOOKED = "BOOKED"
class SeatType(Enum):
REGULAR = "REGULAR"
VIP = "VIP"
class Payment(ABC):
@abstractmethod
def pay(self, amount: float) -> bool:
pass
class AlwaysSucceedsPayment(Payment):
def pay(self, amount: float) -> bool:
return True
class AlwaysFailsPayment(Payment):
def pay(self, amount: float) -> bool:
return False
class Movie:
def __init__(self, movie_id: str, name: str, description: str):
self.id = movie_id
self.name = name
self.description = description
class Seat:
def __init__(self, seat_id: str, seat_type: SeatType):
self.id = seat_id
self.seat_type = seat_type
class Auditorium:
def __init__(self, auditorium_id: str):
self.id = auditorium_id
self.seats: Dict[str, Seat] = {}
def add_seat(self, seat: Seat) -> None:
self.seats[seat.id] = seat
class Show:
def __init__(self, show_id: str, movie: Movie, auditorium: Auditorium,
time: datetime, price: Dict[SeatType, float]):
self.id = show_id
self.movie = movie
self.auditorium = auditorium
self.time = time
self.price = price
self.seat_status: Dict[str, BookStatus] = {
seat_id: BookStatus.AVAILABLE for seat_id in auditorium.seats
}
self._lock = threading.Lock()
def lock_seats(self, seats: List[Seat]) -> bool:
with self._lock:
if any(self.seat_status.get(s.id) != BookStatus.AVAILABLE
for s in seats):
return False # all-or-nothing: change nothing
for s in seats:
self.seat_status[s.id] = BookStatus.LOCKED
return True
def mark_booked(self, seats: List[Seat]) -> bool:
with self._lock:
if any(self.seat_status.get(s.id) != BookStatus.LOCKED
for s in seats):
return False
for s in seats:
self.seat_status[s.id] = BookStatus.BOOKED
return True
def release(self, seats: List[Seat]) -> None:
with self._lock:
for s in seats:
# Only release locks: never un-book a sold seat
if self.seat_status.get(s.id) == BookStatus.LOCKED:
self.seat_status[s.id] = BookStatus.AVAILABLE
def available_seats(self) -> List[Seat]:
with self._lock:
return [self.auditorium.seats[sid]
for sid, status in self.seat_status.items()
if status == BookStatus.AVAILABLE]
class Ticket:
def __init__(self, show: Show, seats: List[Seat]):
self.id = str(uuid.uuid4())
self.show = show
self.seats = seats
self.status = BookStatus.BOOKED
class BookingSystem:
def __init__(self, shows: Dict[str, Show]):
self.shows = shows
def lock_seats(self, show_id: str, seats: List[Seat]) -> bool:
show = self.shows.get(show_id)
return show is not None and show.lock_seats(seats)
def confirm_booking(self, show_id: str, seats: List[Seat],
payment: Payment) -> Optional[Ticket]:
show = self.shows.get(show_id)
if show is None:
return None
amount = sum(show.price[s.seat_type] for s in seats)
if not payment.pay(amount):
show.release(seats) # payment failed: free the locks
return None
# The LOCKED check happens under the show's lock
if not show.mark_booked(seats):
return None # see trade-offs: would need a refund here
return Ticket(show, seats)
def available_seats(self, show_id: str) -> List[Seat]:
show = self.shows.get(show_id)
return show.available_seats() if show else []
if __name__ == "__main__":
aud = Auditorium("A1")
for sid in ["r1", "r2", "r3"]:
aud.add_seat(Seat(sid, SeatType.REGULAR))
movie = Movie("m1", "Dune", "sci-fi")
prices = {SeatType.REGULAR: 12.0, SeatType.VIP: 20.0}
show = Show("s1", movie, aud, datetime.now(), prices)
system = BookingSystem({"s1": show})
seats = [aud.seats["r1"], aud.seats["r2"]]
def free():
return [s.id for s in system.available_seats("s1")]
print("available:", free()) # r1 r2 r3
print("lock:", system.lock_seats("s1", seats)) # True
print("lock again:", system.lock_seats("s1", seats)) # False
failed = system.confirm_booking("s1", seats, AlwaysFailsPayment())
print("confirm (fail):", failed) # None
print("after fail:", free()) # r1 r2 r3
system.lock_seats("s1", seats)
ticket = system.confirm_booking("s1", seats, AlwaysSucceedsPayment())
print("ticket issued:", ticket is not None) # True
print("after book:", free()) # r3
Extensions and trade-offs#
This version is deliberately simple. The follow-ups expose its gaps:
- Lock ownership: right now anyone can confirm or release any locked seat. Return a
hold_idfromlock_seats, storeseat → hold_id, and require it on confirm and release. - Lock expiry: a user who abandons checkout holds seats forever. Store
locked_untiland treat expired locks as AVAILABLE (lazily on read, or with a sweeper). - Pay-then-confirm gap: if
mark_bookedfails after payment succeeds (e.g. the lock expired), you've charged for nothing. Either refund in that branch, or verify the hold is still valid under the lock before charging and extend it during payment. - Pricing: move prices into a
PricingStrategy(weekend, matinee, dynamic). - Seat layout: store seats as
rows × colsto support "pick adjacent seats" queries. - Scaling out: with many servers, an in-process lock isn't enough. Use the database: a row per
(show_id, seat_id)with a conditional update (UPDATE ... SET status='LOCKED' WHERE status='AVAILABLE') orSELECT ... FOR UPDATE, or a distributed lock with TTL.
Key takeaways#
- Seat availability lives on the show, not the auditorium.
- Two-phase booking: lock (all-or-nothing) → pay → book or release.
- Keep every read-then-write of seat status inside one object, under one lock.
- Lock per show so unrelated shows don't contend.
- The follow-ups are lock ownership, expiry, the pay/confirm gap, and database-level locking.