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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.