Design a Ticket Booking System: Seat Reservation
Every ticket booking system faces the same fundamental challenge: multiple users want the same seat at the same time, and only one can win. Whether you're building for movie theaters, concert venues,...
Key Insights
- Seat reservation systems live and die by their concurrency strategy—optimistic locking works for most venues, but high-demand events need distributed locks with Redis to prevent overselling.
- Temporary holds with TTL expiration are non-negotiable; without them, you’ll either lose sales to abandoned carts or frustrate users who can’t complete checkout.
- The data model should treat seat state as a finite state machine with explicit transitions, making it impossible to reach invalid states even under race conditions.
Introduction & Problem Statement
Every ticket booking system faces the same fundamental challenge: multiple users want the same seat at the same time, and only one can win. Whether you’re building for movie theaters, concert venues, or airlines, the core problem remains identical—managing scarce inventory under high concurrency.
The requirements break down clearly:
Functional requirements:
- Display real-time seat availability
- Allow users to select and hold seats temporarily
- Process reservations atomically with payment
- Handle cancellations and refunds
Non-functional requirements:
- Sub-second response times for availability checks
- Zero double-bookings (this is non-negotiable)
- Handle traffic spikes during popular event releases
- Graceful degradation under extreme load
The tricky part isn’t any single feature—it’s making them all work together when thousands of users hit your system simultaneously for a Taylor Swift concert release.
High-Level Architecture
A well-designed booking system separates concerns into distinct services that can scale independently.
┌─────────────┐ ┌─────────────┐ ┌─────────────────┐
│ Client │────▶│ API Gateway │────▶│ Booking Service │
└─────────────┘ └─────────────┘ └────────┬────────┘
│
┌────────────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Seat Inventory│ │ Payment Service │ │ Notification │
│ Service │ │ │ │ Service │
└───────┬───────┘ └──────────────────┘ └─────────────────┘
│
┌──────────┴──────────┐
▼ ▼
┌─────────┐ ┌─────────┐
│ Postgres│ │ Redis │
│ (State) │ │ (Locks) │
└─────────┘ └─────────┘
For database choices, use PostgreSQL for your source of truth. Seat inventory is inherently relational—venues have sections, sections have rows, rows have seats, events map to venues. NoSQL might seem appealing for “scale,” but you’ll regret it when you need transactional guarantees across seat reservations.
Redis handles the hot path: distributed locks, temporary holds, and caching availability data. Message queues (RabbitMQ or SQS) handle async operations like sending confirmation emails and processing refunds.
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from typing import List, Optional
class SeatStatus(Enum):
AVAILABLE = "available"
HELD = "held"
RESERVED = "reserved"
BOOKED = "booked"
@dataclass
class Seat:
id: str
event_id: str
section: str
row: str
number: int
status: SeatStatus
price_cents: int
class SeatInventoryService(ABC):
@abstractmethod
def get_available_seats(self, event_id: str, section: Optional[str] = None) -> List[Seat]:
pass
@abstractmethod
def hold_seats(self, seat_ids: List[str], user_id: str, hold_duration_seconds: int = 600) -> str:
"""Returns hold_token or raises SeatUnavailableError"""
pass
@abstractmethod
def confirm_reservation(self, hold_token: str, payment_id: str) -> str:
"""Returns booking_id or raises HoldExpiredError"""
pass
Data Modeling for Seats & Reservations
Your schema needs to enforce invariants at the database level. Don’t rely on application code to prevent invalid states.
CREATE TYPE seat_status AS ENUM ('available', 'held', 'reserved', 'booked');
CREATE TABLE venues (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
address TEXT,
total_capacity INTEGER NOT NULL
);
CREATE TABLE events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
venue_id UUID REFERENCES venues(id),
name VARCHAR(255) NOT NULL,
event_date TIMESTAMP WITH TIME ZONE NOT NULL,
sales_start TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE seats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID REFERENCES events(id),
section VARCHAR(50) NOT NULL,
row VARCHAR(10) NOT NULL,
seat_number INTEGER NOT NULL,
price_cents INTEGER NOT NULL,
status seat_status DEFAULT 'available',
version INTEGER DEFAULT 1,
held_by UUID,
held_until TIMESTAMP WITH TIME ZONE,
UNIQUE(event_id, section, row, seat_number)
);
CREATE TABLE bookings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
event_id UUID REFERENCES events(id),
payment_id VARCHAR(255),
total_cents INTEGER NOT NULL,
status VARCHAR(50) DEFAULT 'confirmed',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
idempotency_key VARCHAR(255) UNIQUE
);
CREATE TABLE booking_seats (
booking_id UUID REFERENCES bookings(id),
seat_id UUID REFERENCES seats(id),
PRIMARY KEY (booking_id, seat_id)
);
-- Critical indexes for performance
CREATE INDEX idx_seats_event_status ON seats(event_id, status);
CREATE INDEX idx_seats_held_until ON seats(held_until) WHERE status = 'held';
CREATE INDEX idx_events_sales_start ON events(sales_start);
Fetching available seats needs to be fast:
SELECT id, section, row, seat_number, price_cents
FROM seats
WHERE event_id = $1
AND section = $2
AND status = 'available'
ORDER BY row, seat_number;
Handling Concurrent Seat Selection
The double-booking problem occurs when two users select the same seat simultaneously. Both read the seat as available, both attempt to reserve it, and without proper locking, both succeed.
Pessimistic locking prevents concurrent access entirely:
async def hold_seats_pessimistic(
db: AsyncSession,
seat_ids: List[str],
user_id: str
) -> str:
# Lock rows for update - blocks other transactions
result = await db.execute(
text("""
SELECT id FROM seats
WHERE id = ANY(:seat_ids)
AND status = 'available'
FOR UPDATE NOWAIT
"""),
{"seat_ids": seat_ids}
)
available = [row.id for row in result.fetchall()]
if len(available) != len(seat_ids):
raise SeatUnavailableError("One or more seats no longer available")
hold_token = str(uuid4())
hold_until = datetime.utcnow() + timedelta(minutes=10)
await db.execute(
text("""
UPDATE seats
SET status = 'held', held_by = :user_id, held_until = :hold_until
WHERE id = ANY(:seat_ids)
"""),
{"seat_ids": seat_ids, "user_id": user_id, "hold_until": hold_until}
)
return hold_token
Optimistic locking allows concurrent reads but detects conflicts at write time:
async def hold_seats_optimistic(
db: AsyncSession,
seat_ids: List[str],
user_id: str,
expected_versions: Dict[str, int]
) -> str:
hold_token = str(uuid4())
hold_until = datetime.utcnow() + timedelta(minutes=10)
for seat_id in seat_ids:
result = await db.execute(
text("""
UPDATE seats
SET status = 'held',
held_by = :user_id,
held_until = :hold_until,
version = version + 1
WHERE id = :seat_id
AND status = 'available'
AND version = :expected_version
RETURNING id
"""),
{
"seat_id": seat_id,
"user_id": user_id,
"hold_until": hold_until,
"expected_version": expected_versions[seat_id]
}
)
if result.rowcount == 0:
await db.rollback()
raise SeatUnavailableError(f"Seat {seat_id} was modified by another transaction")
return hold_token
For high-demand events, use Redis distributed locks to gate access before hitting the database:
import redis
async def acquire_seat_lock(redis_client: redis.Redis, seat_id: str, user_id: str) -> bool:
lock_key = f"seat_lock:{seat_id}"
# SETNX with expiration - atomic operation
acquired = redis_client.set(
lock_key,
user_id,
nx=True, # Only set if not exists
ex=30 # 30 second expiration
)
return acquired is not None
async def release_seat_lock(redis_client: redis.Redis, seat_id: str, user_id: str):
lock_key = f"seat_lock:{seat_id}"
# Only release if we own the lock (Lua script for atomicity)
lua_script = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
"""
redis_client.eval(lua_script, 1, lock_key, user_id)
Temporary Hold & Reservation Expiry
Users need time to complete checkout. A 10-minute hold is standard—long enough to enter payment details, short enough to not frustrate other buyers.
# Store hold in Redis with TTL
async def create_hold(
redis_client: redis.Redis,
hold_token: str,
seat_ids: List[str],
user_id: str,
ttl_seconds: int = 600
):
hold_data = {
"user_id": user_id,
"seat_ids": seat_ids,
"created_at": datetime.utcnow().isoformat()
}
redis_client.setex(
f"hold:{hold_token}",
ttl_seconds,
json.dumps(hold_data)
)
# Background job to clean up expired holds
async def cleanup_expired_holds(db: AsyncSession):
"""Run every minute via scheduler"""
result = await db.execute(
text("""
UPDATE seats
SET status = 'available', held_by = NULL, held_until = NULL
WHERE status = 'held'
AND held_until < NOW()
RETURNING id
""")
)
released_count = result.rowcount
logger.info(f"Released {released_count} expired seat holds")
Scaling & Performance Considerations
Cache availability aggressively, but invalidate correctly:
async def get_seat_availability_cached(
redis_client: redis.Redis,
db: AsyncSession,
event_id: str,
section: str
) -> List[dict]:
cache_key = f"availability:{event_id}:{section}"
# Try cache first
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
# Cache miss - query database
result = await db.execute(
text("""
SELECT id, row, seat_number, price_cents, status
FROM seats
WHERE event_id = :event_id AND section = :section
"""),
{"event_id": event_id, "section": section}
)
seats = [dict(row._mapping) for row in result.fetchall()]
# Cache for 5 seconds - short TTL for near-real-time accuracy
redis_client.setex(cache_key, 5, json.dumps(seats, default=str))
return seats
Failure Handling & Edge Cases
Idempotency keys prevent duplicate bookings when clients retry:
async def create_booking(
db: AsyncSession,
user_id: str,
hold_token: str,
payment_id: str,
idempotency_key: str
) -> dict:
# Check for existing booking with this idempotency key
existing = await db.execute(
text("SELECT id FROM bookings WHERE idempotency_key = :key"),
{"key": idempotency_key}
)
if existing.fetchone():
# Return existing booking - safe retry
return await get_booking_by_idempotency_key(db, idempotency_key)
try:
# Verify hold is still valid, process booking
booking_id = await process_booking(db, hold_token, payment_id, user_id, idempotency_key)
await db.commit()
return {"booking_id": booking_id, "status": "confirmed"}
except PaymentFailedError:
await db.rollback()
await release_hold(db, hold_token)
raise
The key insight: treat your booking system as a state machine with explicit, validated transitions. Seats move from available → held → reserved → booked, and each transition has preconditions that must be checked atomically. Get this right, and you’ll never oversell. Get it wrong, and you’ll be issuing refunds and apologies.