Build a Cinema Seat Booking API

Build a FastAPI booking service and stop double bookings with PostgreSQL locks.

Introduction

30 Second Summary

Seat booking feels simple until two people choose the same chair at nearly the same moment. A booking system can show both buyers a confirmation unless it protects the decision.

In this project, you will build a cinema booking service that makes this failure visible. You'll repair it with a protected database transaction before turning the result into an interview-ready design.

What You'll Build

You'll run two buyers at seat A1, watch both receive confirmation, then lock the seat and rerun the test to see one booking succeed while the competing request is rejected.

By the end of this project, you'll have:

  • A browser-visible seat inventory running through Docker Compose with FastAPI, where you can inspect seats A1 through A4 plus each seat's status, owner, and booking count.
  • A repeatable race condition test that exposes two confirmations for one seat, then proves a PostgreSQL row lock produces one success plus one conflict.
  • An interview-ready system design you can preview in Visual Studio Code and use to explain requirements, transaction boundaries, bottlenecks, and scaling in 90 seconds.
  • Secret Mission: Design a temporary seat-hold flow with expiry, customer ownership, and idempotent booking confirmation.

Do I need any experience before starting?

Basic Python syntax plus terminal navigation are enough for this project. You'll use Docker Desktop on your Mac because Python and PostgreSQL run inside containers.

Before We Start

Before any hands-on work, lock in the rule that guides every design choice. You are building a cinema booking service with Docker, FastAPI, and PostgreSQL where at most one booking can succeed for each (show_id, seat_id).

Launch the API and Database

Your booking service needs a reproducible home before it can protect seats. A containerized stack keeps the lesson focused on booking logic.

In this step, Docker Compose coordinates a FastAPI service with a PostgreSQL database. You will assemble both services before launching a browser-visible health route.

In this step, get ready to:
  • Verify Docker Desktop and Docker Compose from the macOS terminal.
  • Create the container configuration and seeded database files.
  • Launch the stack and confirm the API health response.
Verify Docker from the terminal

Docker Desktop provides the container engine that runs both services. The terminal checks confirm that your Mac can reach the engine before you create the project.

  • Press Cmd+Space to open Spotlight.
  • Type Docker Desktop into Spotlight.
  • Press Enter to return to Docker Desktop.
  • Wait for Docker Desktop to finish starting.

You will see Docker Desktop indicate that its container engine is ready.

  • Press Cmd+Space to open Spotlight again.
  • Type Terminal into Spotlight.
  • Press Enter to open the macOS terminal.
  • Verify the Docker engine and Compose plugin by running these commands:
docker version
docker compose version

What do these commands check?

  • The first command checks that the Docker client can communicate with the running engine.
  • The second command confirms that the Compose plugin is available for coordinating multiple services.

You will see client information plus server information from the first command. You will see Compose version information from the second command.

Docker details missing?

  • Make sure Docker Desktop has finished starting before you rerun the commands.
  • Confirm that the second command includes a space between docker and compose.

Still stuck? Help me verify Docker Desktop and Docker Compose on macOS

Create the service files

Visual Studio Code keeps the API code beside the container configuration and relational schema. Each file gives one part of the stack a clear responsibility.

  • Return to Visual Studio Code.
  • Select File from the menu bar.
  • Select Open Folder....
  • Select your Desktop in the macOS folder picker.
  • Select New Folder.
  • Enter cinema-booking as the folder name.
  • Select Open to use cinema-booking as the workspace.

The Explorer sidebar now shows cinema-booking as the workspace folder.

  • Select Yes, I trust the authors if the Workspace Trust dialog appears.
  • Select New Folder in the Explorer sidebar.
  • Enter app as the first folder name.
  • Select New Folder again.
  • Enter db as the second folder name.

The Explorer now shows the empty app and db folders.

  • Select the cinema-booking folder in the Explorer sidebar.
  • Select New File....
  • Enter requirements.txt as the file name.
  • Press Enter to create the file.
  • Add the pinned Python dependencies by pasting this code:
fastapi[standard-no-fastapi-cloud-cli]==0.141.1
pydantic==2.13.5
psycopg[binary]==3.3.5

What do these dependencies provide?

  • FastAPI 0.141.1 supplies the web framework and server command used by the container.
  • Pydantic 2.13.5 supplies request validation for the booking routes added later.
  • Psycopg 3.3.5 supplies the PostgreSQL driver used by the API.
  • Save requirements.txt.
  • Confirm that the Explorer lists requirements.txt inside cinema-booking.

Dependency file looks different?

  • Check that the filename is exactly requirements.txt.
  • Keep each dependency on its own line.

Need help? Help me check my pinned Python dependency file

  • Select the cinema-booking folder in the Explorer sidebar.
  • Select New File....
  • Enter Dockerfile as the file name.
  • Press Enter to create the file.
  • Define the API image by pasting this code:
FROM python:3.14.7-slim

WORKDIR /code

COPY ./requirements.txt /code/requirements.txt

RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt

COPY ./app /code/app

CMD ["fastapi", "run", "app/main.py", "--port", "8000"]

What does the Dockerfile build?

  • The Docker image starts from Python 3.14.7.
  • The dependency layer installs the packages from requirements.txt.
  • The application layer copies the app folder into /code/app.
  • The final instruction starts FastAPI on port 8000.
  • Save Dockerfile.
  • Confirm that the Explorer lists Dockerfile without a file extension.

Dockerfile not recognised?

  • Check that the filename starts with a capital D.
  • Remove any hidden .txt extension from the filename.

Need help? Help me check my API Dockerfile

  • Select the cinema-booking folder in the Explorer sidebar.
  • Select New File....
  • Enter compose.yaml as the file name.
  • Press Enter to create the file.
  • Define the database and API services by pasting this code:
services:
  db:
    image: postgres:18.6
    environment:
      POSTGRES_DB: cinema
      POSTGRES_USER: cinema
      POSTGRES_PASSWORD: cinema
    volumes:
      - postgres-data:/var/lib/postgresql
      - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 2s
      timeout: 5s
      retries: 10

  api:
    build: .
    ports:
      - "127.0.0.1:8000:8000"
    depends_on:
      db:
        condition: service_healthy

volumes:
  postgres-data:

How do the services work together?

  • The db service runs PostgreSQL 18.6 with a persistent named volume.
  • The database healthcheck confirms that PostgreSQL accepts connections.
  • The api service waits for a healthy database before it starts.
  • The port mapping publishes the API only at 127.0.0.1:8000 on your Mac.
  • Save compose.yaml.
  • Confirm that the Explorer lists compose.yaml inside cinema-booking.

Compose file showing warnings?

  • Check that spaces preserve the YAML indentation shown above.
  • Confirm that the filename ends with .yaml.
  • Keep the local database password confined to this isolated project.

Need help? Help me check my Docker Compose configuration

The database starts from a fixed set of tables and rows. This seed data gives every learner the same show plus the same four available seats.

  • Select the db folder in the Explorer sidebar.
  • Select New File....
  • Enter init.sql as the file name.
  • Press Enter to create the file.
  • Define the show table by pasting this code:
CREATE TABLE shows (
    id integer PRIMARY KEY,
    movie_title text NOT NULL,
    starts_at timestamptz NOT NULL
);

What does the show table store?

  • The primary key gives each show one database identity.
  • The remaining columns store its movie title and scheduled start time.
  • Save db/init.sql.
  • Confirm that the editor shows the complete shows table definition.

Show table incomplete?

  • Check that each column appears inside the parentheses.
  • Confirm that the definition ends with a closing parenthesis plus a semicolon.

Need help? Help me compare my shows table definition

  • Place your cursor after the shows table definition in db/init.sql.
  • Add the per-show seat state by pasting this code:
CREATE TABLE show_seats (
    show_id integer NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
    seat_id text NOT NULL,
    status text NOT NULL DEFAULT 'available',
    booked_by text,
    PRIMARY KEY (show_id, seat_id),
    CONSTRAINT valid_seat_status CHECK (status IN ('available', 'booked')),
    CONSTRAINT valid_booking_owner CHECK (
        (status = 'available' AND booked_by IS NULL)
        OR
        (status = 'booked' AND booked_by IS NOT NULL)
    )
);

How does the seat state stay valid?

  • The composite primary key scopes each seat to one show.
  • The status constraint permits only available or booked.
  • The owner constraint requires a booked seat to have a customer.
  • The same constraint requires an available seat to have no customer.
  • Save db/init.sql.
  • Confirm that the show_seats table appears below the shows table.

Seat constraints look broken?

  • Check that both constraint names appear inside show_seats.
  • Confirm that every opening parenthesis has a matching closing parenthesis.

Need help? Help me compare my show_seats table and constraints

  • Place your cursor after the show_seats table definition.
  • Add the booking record table by pasting this code:
CREATE TABLE bookings (
    show_id integer NOT NULL,
    seat_id text NOT NULL,
    customer text NOT NULL,
    booked_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (show_id, seat_id),
    FOREIGN KEY (show_id, seat_id)
        REFERENCES show_seats(show_id, seat_id)
        ON DELETE CASCADE
);

Why keep booking records separate?

  • The table stores the customer plus the time of each successful booking.
  • Its composite primary key provides a database-level uniqueness backstop for each show and seat.
  • Its foreign key ties every booking to an existing seat state.
  • Save db/init.sql.
  • Confirm that the bookings table appears below show_seats.

Booking table incomplete?

  • Check that the primary key includes both show_id and seat_id.
  • Confirm that the foreign key references the same two columns in show_seats.

Need help? Help me check my bookings table definition

  • Place your cursor after the bookings table definition.
  • Seed the demonstration show and four seats by pasting this code:
INSERT INTO shows (id, movie_title, starts_at)
VALUES (1, 'Concurrency at the Cinema', '2026-10-01 19:00:00+00');

INSERT INTO show_seats (show_id, seat_id)
VALUES
    (1, 'A1'),
    (1, 'A2'),
    (1, 'A3'),
    (1, 'A4');

What gets seeded?

  • Show 1 is titled Concurrency at the Cinema.
  • Seats A1 through A4 inherit the default available status.
  • The initialization file runs when PostgreSQL starts with an empty data directory.
  • Save db/init.sql.
  • Confirm that the file ends with the row for seat A4.

Seed rows missing?

  • Check that every seat row uses show ID 1.
  • Confirm that commas separate the first three seat rows.
  • Confirm that the final seat row ends with a semicolon.

Need help? Help me check the seeded show and seats

Add the health route and start the stack

A health route gives you the smallest visible proof that the API container is serving requests. Its response identifies the service plus its current status.

  • Select the app folder in the Explorer sidebar.
  • Select New File....
  • Enter main.py as the file name.
  • Press Enter to create the file.
  • Define the FastAPI application and health route by pasting this code:
from fastapi import FastAPI

app = FastAPI(title="Cinema Seat Booking API")


@app.get("/")
def health():
    return {"service": "cinema-booking", "status": "ok"}

What does this code do?

  • The import makes the FastAPI application class available.
  • The app object defines the web service named Cinema Seat Booking API.
  • The health() function handles requests to the root path.
  • The returned dictionary becomes the JSON health response.
  • Save app/main.py.
  • Confirm that the Explorer lists main.py inside app.

Python file showing errors?

  • Check that the decorator sits directly above health().
  • Confirm that the return statement is indented inside the function.

Need help? Help me compare my FastAPI health route

Use these tabs to compare every project file before the first build.

✔️ My files match

Great. Save every open file before starting the stack.

ⓧ I want to double-check

Compare your dependency file with this complete version.

fastapi[standard-no-fastapi-cloud-cli]==0.141.1
pydantic==2.13.5
psycopg[binary]==3.3.5

Compare your image definition with this complete version.

FROM python:3.14.7-slim

WORKDIR /code

COPY ./requirements.txt /code/requirements.txt

RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt

COPY ./app /code/app

CMD ["fastapi", "run", "app/main.py", "--port", "8000"]

Compare your service configuration with this complete version.

services:
  db:
    image: postgres:18.6
    environment:
      POSTGRES_DB: cinema
      POSTGRES_USER: cinema
      POSTGRES_PASSWORD: cinema
    volumes:
      - postgres-data:/var/lib/postgresql
      - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 2s
      timeout: 5s
      retries: 10

  api:
    build: .
    ports:
      - "127.0.0.1:8000:8000"
    depends_on:
      db:
        condition: service_healthy

volumes:
  postgres-data:

Compare your database initialization file with this complete version.

CREATE TABLE shows (
    id integer PRIMARY KEY,
    movie_title text NOT NULL,
    starts_at timestamptz NOT NULL
);

CREATE TABLE show_seats (
    show_id integer NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
    seat_id text NOT NULL,
    status text NOT NULL DEFAULT 'available',
    booked_by text,
    PRIMARY KEY (show_id, seat_id),
    CONSTRAINT valid_seat_status CHECK (status IN ('available', 'booked')),
    CONSTRAINT valid_booking_owner CHECK (
        (status = 'available' AND booked_by IS NULL)
        OR
        (status = 'booked' AND booked_by IS NOT NULL)
    )
);

CREATE TABLE bookings (
    show_id integer NOT NULL,
    seat_id text NOT NULL,
    customer text NOT NULL,
    booked_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (show_id, seat_id),
    FOREIGN KEY (show_id, seat_id)
        REFERENCES show_seats(show_id, seat_id)
        ON DELETE CASCADE
);

INSERT INTO shows (id, movie_title, starts_at)
VALUES (1, 'Concurrency at the Cinema', '2026-10-01 19:00:00+00');

INSERT INTO show_seats (show_id, seat_id)
VALUES
    (1, 'A1'),
    (1, 'A2'),
    (1, 'A3'),
    (1, 'A4');

Compare your API module with this complete version.

from fastapi import FastAPI

app = FastAPI(title="Cinema Seat Booking API")


@app.get("/")
def health():
    return {"service": "cinema-booking", "status": "ok"}

The first build can take a few minutes while Docker downloads the images and installs the Python packages. A quiet pause during this first run is expected.

  • Right-click the cinema-booking folder in the Explorer sidebar.
  • Select Open in Integrated Terminal.
  • Start the database and API services by running this command:
docker compose up --build -d

What does this command do?

  • Compose builds the local API image from Dockerfile.
  • Compose creates the db container plus its persistent volume.
  • PostgreSQL runs db/init.sql while initializing the empty data directory.
  • Compose starts the api container after the database healthcheck passes.

The command finishes after building the API image and starting both services in the background.

Stack not starting?

  • Confirm that Docker Desktop is still running.
  • Check that the terminal is inside the cinema-booking workspace.
  • Compare each filename with the full-code tab above.

Need help? Help me troubleshoot my Docker Compose build

Before you load the health route, which service name and status do you expect the API to report?

  • Enter http://127.0.0.1:8000/ in your browser address bar.

You will see {"service":"cinema-booking","status":"ok"} in the browser.

That is your first live result. Your API now responds after PostgreSQL passes its healthcheck.

Health response not loading?

  • Confirm that the address uses port 8000.
  • Wait for the first image build to finish before reloading the page.
  • Check that app/main.py is saved inside the app folder.

Still stuck? Help me diagnose the missing FastAPI health response

Your two-service stack is live with a seeded cinema database behind it. Next up, you will expose the four seats through the API.

Expose Live Seat Inventory

Your Docker Compose stack already proves that the FastAPI service can start beside PostgreSQL. However, the health route cannot show which seats a customer can book.

A live seat inventory turns the seeded database rows into the first useful booking view.

In this step, you'll connect the API to the database. The browser will show every seat with its current booking state.

In this step, get ready to:
  • Connect the FastAPI service to PostgreSQL with Psycopg.
  • Build the live seat inventory endpoint.
  • Verify the returned inventory in the browser.
Connect the API to PostgreSQL

The DB_DSN constant tells Psycopg how to reach the db service. HTTPException gives the route a clear response when the show does not exist.

  • In app/main.py, find the existing FastAPI import:
from fastapi import FastAPI

What are you changing?

The current import provides the application class used by your health route. The next block adds the database client plus the API error type.

  • Replace the existing import with the database access block below:
import psycopg
from fastapi import FastAPI, HTTPException

DB_DSN = "dbname=cinema user=cinema password=cinema host=db"

What does this code do?

  • The psycopg import provides the database connection used by the inventory route.
  • The DB_DSN value points to the cinema database inside the db Compose service.
  • The HTTPException import lets the endpoint return a 404 response for an unknown show.
Build the inventory response

The list_seats() endpoint needs one result that combines show details with seat state. Its query also counts successful booking records for each seat.

  • In app/main.py, place the first part of list_seats() below health():
@app.get("/shows/{show_id}/seats")
def list_seats(show_id: int):
    with psycopg.connect(DB_DSN) as conn:
        rows = conn.execute(
            """
            SELECT
                s.movie_title,
                s.starts_at::text,
                ss.seat_id,
                ss.status,
                ss.booked_by,
                (
                    SELECT COUNT(*)
                    FROM bookings AS b
                    WHERE b.show_id = ss.show_id
                      AND b.seat_id = ss.seat_id
                ) AS booking_count
            FROM shows AS s
            JOIN show_seats AS ss ON ss.show_id = s.id
            WHERE s.id = %s
            ORDER BY ss.seat_id
            """,
            (show_id,),
        ).fetchall()

    if not rows:
        raise HTTPException(status_code=404, detail="Show not found")

How does the query build the inventory?

  • The query joins shows with show_seats so every row includes the movie details plus one seat.
  • The nested COUNT(*) reads bookings to calculate the booking total for that seat.
  • The ORDER BY clause keeps the seats in identifier order.
  • The final check returns a clear error when the requested show has no rows.
  • Directly below the HTTPException line, add the response block:
    return {
        "show_id": show_id,
        "movie_title": rows[0][0],
        "starts_at": rows[0][1],
        "seats": [
            {
                "seat_id": row[2],
                "status": row[3],
                "booked_by": row[4],
                "booking_count": row[5],
            }
            for row in rows
        ],
    }

How is the response shaped?

The outer fields describe show 1. The list comprehension turns each query row into a seat object.

Each seat exposes its identifier plus its current status. It also exposes the owner plus the number of booking records.

✔️ Awesome, I've got everything!

Your app/main.py now contains the database connection plus the complete inventory route.

ⓧ I'd like to double check the full code

import psycopg
from fastapi import FastAPI, HTTPException

DB_DSN = "dbname=cinema user=cinema password=cinema host=db"

app = FastAPI(title="Cinema Seat Booking API")


@app.get("/")
def health():
    return {"service": "cinema-booking", "status": "ok"}


@app.get("/shows/{show_id}/seats")
def list_seats(show_id: int):
    with psycopg.connect(DB_DSN) as conn:
        rows = conn.execute(
            """
            SELECT
                s.movie_title,
                s.starts_at::text,
                ss.seat_id,
                ss.status,
                ss.booked_by,
                (
                    SELECT COUNT(*)
                    FROM bookings AS b
                    WHERE b.show_id = ss.show_id
                      AND b.seat_id = ss.seat_id
                ) AS booking_count
            FROM shows AS s
            JOIN show_seats AS ss ON ss.show_id = s.id
            WHERE s.id = %s
            ORDER BY ss.seat_id
            """,
            (show_id,),
        ).fetchall()

    if not rows:
        raise HTTPException(status_code=404, detail="Show not found")

    return {
        "show_id": show_id,
        "movie_title": rows[0][0],
        "starts_at": rows[0][1],
        "seats": [
            {
                "seat_id": row[2],
                "status": row[3],
                "booked_by": row[4],
                "booking_count": row[5],
            }
            for row in rows
        ],
    }

What should the full file contain?

The full file keeps the original health route. It adds the database connection plus the complete inventory route beneath it.

Verify the live inventory

The running API container still uses the earlier copy of app/main.py. Rebuilding the image copies your updated module into the service.

  • Save app/main.py.
  • Rebuild the running stack by running this command:
docker compose up --build -d

What does this command do?

The docker compose up command reconciles the existing services with the current project files. The --build flag rebuilds the API image.

The -d flag leaves the services running in the background. The API starts after the database passes its configured health check.

You'll see the existing database service remain healthy. The rebuilt API service returns to its running state.

Before you check the endpoint, consider whether the response will contain one seat or all four seeded seats.

  • Return to the browser tab from earlier.
  • Enter http://127.0.0.1:8000/shows/1/seats in the address bar.

You'll see show 1 titled Concurrency at the Cinema. The response also includes the show's start time.

Seats A1 through A4 each show available with a booking count of 0. Each booked_by value is null.

  • Open a new browser tab.
  • Enter http://127.0.0.1:8000/docs in the address bar.
  • Locate GET /shows/{show_id}/seats in the generated documentation.

You'll see the live inventory route listed with its show_id path input. This confirms that FastAPI registered the endpoint.

Don't see four available seats?

  • Confirm that app/main.py matches the full-code reference.
  • Check that DB_DSN uses db as the database host.
  • Confirm that the API service finished rebuilding before refreshing the endpoint.

Still stuck? Help me troubleshoot my FastAPI seat inventory endpoint.

That is the first useful slice working. Your API now turns seeded rows into a live seat map.

Next, you'll put the booking path under concurrent pressure so its weakness becomes visible.

Expose the Booking Race

Your FastAPI inventory now shows the live seat state stored in PostgreSQL.

A normal read-check-write flow looks safe when one buyer uses it. A race condition appears when two buyers read the same available seat before either update finishes.

In this step, get ready to:
  • Add a reset route for repeatable booking tests.
  • Create an intentionally unsafe booking route with a one-second race window.
  • Send two synchronized buyers to the same seat and capture the broken invariant.
Build the vulnerable seat path

The demonstration needs a repeatable starting state for seat A1. A shared seat query also keeps the availability check consistent across the demo routes.

  • In app/main.py, select everything from the first import through the health() function.
  • Replace the selected code with this request foundation:
import time

import psycopg
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

DB_DSN = "dbname=cinema user=cinema password=cinema host=db"

app = FastAPI(title="Cinema Seat Booking API")


class BookingRequest(BaseModel):
    customer: str


@app.get("/")
def health():
    return {"service": "cinema-booking", "status": "ok"}


def fetch_seat(conn, show_id: int, seat_id: str, lock: bool = False):
    query = """
        SELECT status, booked_by
        FROM show_seats
        WHERE show_id = %s AND seat_id = %s
    """
    if lock:
        query += " FOR UPDATE"
    return conn.execute(query, (show_id, seat_id)).fetchone()

What does this code do?

  • The BookingRequest model requires each booking request to identify a customer.
  • The fetch_seat() helper reads the current status plus the current owner for one show and seat.
  • The lock option prepares this helper for a later comparison while remaining disabled in the naive path.
  • Move the cursor to the end of app/main.py.
  • Add the reset route by pasting this code:
@app.post("/demo/reset/{show_id}/{seat_id}")
def reset_seat(show_id: int, seat_id: str):
    with psycopg.connect(DB_DSN) as conn:
        deleted = conn.execute("DELETE FROM bookings WHERE show_id = %s AND seat_id = %s RETURNING seat_id", (show_id, seat_id)).fetchall()
        seat = conn.execute("UPDATE show_seats SET status = 'available', booked_by = NULL WHERE show_id = %s AND seat_id = %s RETURNING seat_id", (show_id, seat_id)).fetchone()
    if seat is None:
        raise HTTPException(status_code=404, detail="Seat not found")
    return {"seat_id": seat_id, "status": "available", "deleted_bookings": len(deleted)}

How does the reset work?

  • The route deletes any existing booking record for the target seat.
  • The route restores the seat to available with no owner.
  • The connection context commits both changes together when the route succeeds.
  • Save app/main.py.
  • Rebuild the running stack with the updated application code by running:
docker compose up --build -d

What does this command do?

Docker Compose rebuilds the API image so the container receives the updated app/main.py. The database service stays attached to its existing volume.

  • Return to http://127.0.0.1:8000/docs in your browser.
  • Confirm the documentation lists POST /demo/reset/{show_id}/{seat_id}.

Good progress. The API can now restore one seat before every concurrency test.

Don't see the reset route?

  • Refresh the documentation page after the rebuild finishes.
  • Check that the reset decorator sits outside the list_seats() function.
  • Confirm that app/main.py was saved before the image rebuild.

Still stuck? Help me find why my FastAPI reset route is missing from the generated documentation.

Add the naive booking route

The naive path checks availability before it writes the new owner. Its deliberate time.sleep(1) pause widens the overlap between competing requests.

  • In app/main.py, select the existing list_seats() function.
  • Replace that function with this equivalent compact version:
@app.get("/shows/{show_id}/seats")
def list_seats(show_id: int):
    with psycopg.connect(DB_DSN) as conn:
        rows = conn.execute("SELECT s.movie_title, s.starts_at::text, ss.seat_id, ss.status, ss.booked_by, (SELECT COUNT(*) FROM bookings AS b WHERE b.show_id = ss.show_id AND b.seat_id = ss.seat_id) AS booking_count FROM shows AS s JOIN show_seats AS ss ON ss.show_id = s.id WHERE s.id = %s ORDER BY ss.seat_id", (show_id,)).fetchall()
    if not rows:
        raise HTTPException(status_code=404, detail="Show not found")
    return {"show_id": show_id, "movie_title": rows[0][0], "starts_at": rows[0][1], "seats": [{"seat_id": row[2], "status": row[3], "booked_by": row[4], "booking_count": row[5]} for row in rows]}

What stays the same?

The query still joins the show to its seats. The response still reports each seat's status plus its owner and booking count.

  • Move the cursor below the reset_seat() function.
  • Add the unsafe booking route by pasting this code:
@app.post("/demo/shows/{show_id}/seats/{seat_id}/book-naive")
def book_seat_naive(show_id: int, seat_id: str, request: BookingRequest):
    with psycopg.connect(DB_DSN) as conn:
        seat = fetch_seat(conn, show_id, seat_id)
        if seat is None:
            raise HTTPException(status_code=404, detail="Seat not found")
        if seat[0] != "available":
            raise HTTPException(status_code=409, detail="Seat already booked")
        time.sleep(1)
        conn.execute("UPDATE show_seats SET status = 'booked', booked_by = %s WHERE show_id = %s AND seat_id = %s", (request.customer, show_id, seat_id))
    return {"message": "Booking confirmed by unsafe demo path", "show_id": show_id, "seat_id": seat_id, "customer": request.customer}

Where is the vulnerable window?

  • The route first reads the seat through fetch_seat().
  • The availability check accepts any request that observed available.
  • The one-second pause gives another request time to pass the same check.
  • The final update stores an owner without creating a row in bookings.
  • Save app/main.py.
  • Rebuild the API with the naive route by running:
docker compose up --build -d

Why rebuild again?

The Dockerfile copies the app folder into the API image. Rebuilding places the naive route inside the running container.

  • Refresh http://127.0.0.1:8000/docs.
  • Confirm the documentation lists POST /demo/shows/{show_id}/seats/{seat_id}/book-naive.

Is the naive route missing?

  • Check that the route decorator begins at the left edge of app/main.py.
  • Check that BookingRequest is defined above the route.
  • Review the API container output for a Python syntax error without quoting any credential values.

Need another pair of eyes? Help me debug why my naive booking route is missing after rebuilding the API container.

✔️ Awesome, I've got everything!

Your API now has the reset route plus the deliberately unsafe booking route. Double-check that you saved app/main.py.

ⓧ I'd like to double check the full code

import time

import psycopg
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

DB_DSN = "dbname=cinema user=cinema password=cinema host=db"

app = FastAPI(title="Cinema Seat Booking API")


class BookingRequest(BaseModel):
    customer: str


@app.get("/")
def health():
    return {"service": "cinema-booking", "status": "ok"}


def fetch_seat(conn, show_id: int, seat_id: str, lock: bool = False):
    query = """
        SELECT status, booked_by
        FROM show_seats
        WHERE show_id = %s AND seat_id = %s
    """
    if lock:
        query += " FOR UPDATE"
    return conn.execute(query, (show_id, seat_id)).fetchone()


@app.get("/shows/{show_id}/seats")
def list_seats(show_id: int):
    with psycopg.connect(DB_DSN) as conn:
        rows = conn.execute("SELECT s.movie_title, s.starts_at::text, ss.seat_id, ss.status, ss.booked_by, (SELECT COUNT(*) FROM bookings AS b WHERE b.show_id = ss.show_id AND b.seat_id = ss.seat_id) AS booking_count FROM shows AS s JOIN show_seats AS ss ON ss.show_id = s.id WHERE s.id = %s ORDER BY ss.seat_id", (show_id,)).fetchall()
    if not rows:
        raise HTTPException(status_code=404, detail="Show not found")
    return {"show_id": show_id, "movie_title": rows[0][0], "starts_at": rows[0][1], "seats": [{"seat_id": row[2], "status": row[3], "booked_by": row[4], "booking_count": row[5]} for row in rows]}


@app.post("/demo/reset/{show_id}/{seat_id}")
def reset_seat(show_id: int, seat_id: str):
    with psycopg.connect(DB_DSN) as conn:
        deleted = conn.execute("DELETE FROM bookings WHERE show_id = %s AND seat_id = %s RETURNING seat_id", (show_id, seat_id)).fetchall()
        seat = conn.execute("UPDATE show_seats SET status = 'available', booked_by = NULL WHERE show_id = %s AND seat_id = %s RETURNING seat_id", (show_id, seat_id)).fetchone()
    if seat is None:
        raise HTTPException(status_code=404, detail="Seat not found")
    return {"seat_id": seat_id, "status": "available", "deleted_bookings": len(deleted)}


@app.post("/demo/shows/{show_id}/seats/{seat_id}/book-naive")
def book_seat_naive(show_id: int, seat_id: str, request: BookingRequest):
    with psycopg.connect(DB_DSN) as conn:
        seat = fetch_seat(conn, show_id, seat_id)
        if seat is None:
            raise HTTPException(status_code=404, detail="Seat not found")
        if seat[0] != "available":
            raise HTTPException(status_code=409, detail="Seat already booked")
        time.sleep(1)
        conn.execute("UPDATE show_seats SET status = 'booked', booked_by = %s WHERE show_id = %s AND seat_id = %s", (request.customer, show_id, seat_id))
    return {"message": "Booking confirmed by unsafe demo path", "show_id": show_id, "seat_id": seat_id, "customer": request.customer}

How to use this reference

Compare the imports, route order, function names, and SQL strings with your saved file. Matching these details keeps the concurrency test deterministic.

Race two buyers at one seat

A concurrency test needs both buyers to cross the availability check at nearly the same moment. A thread barrier holds alice and bob until both requests are ready.

  • Use the file sidebar in VS Code to create app/race_test.py inside the existing app folder.
  • Paste the HTTP helpers into the new file:
import json
import sys
import threading
import urllib.error
import urllib.request

BASE_URL = "http://127.0.0.1:8000"
SHOW_ID = 1
SEAT_ID = "A1"


def request_json(method: str, path: str, payload=None):
    data = None
    headers = {}
    if payload is not None:
        data = json.dumps(payload).encode("utf-8")
        headers["Content-Type"] = "application/json"
    request = urllib.request.Request(f"{BASE_URL}{path}", data=data, headers=headers, method=method)
    try:
        with urllib.request.urlopen(request, timeout=10) as response:
            return response.status, json.loads(response.read())
    except urllib.error.HTTPError as error:
        return error.code, json.loads(error.read())


def get_inventory():
    with urllib.request.urlopen(f"{BASE_URL}/shows/{SHOW_ID}/seats", timeout=10) as response:
        return json.loads(response.read())

What do these helpers provide?

  • The constants target show 1 and seat A1 on the local API.
  • The request_json() helper sends requests and returns both successful and conflicting HTTP responses in one format.
  • The get_inventory() helper reads the final seat owner plus the booking count.
  • Move the cursor below get_inventory().
  • Add the synchronized race runner and script entry point:
def run_race(mode: str):
    if mode == "naive":
        endpoint = f"/demo/shows/{SHOW_ID}/seats/{SEAT_ID}/book-naive"
    else:
        raise SystemExit("Usage: python app/race_test.py naive")
    reset_status, reset_body = request_json("POST", f"/demo/reset/{SHOW_ID}/{SEAT_ID}")
    if reset_status != 200:
        raise RuntimeError(reset_body)
    barrier = threading.Barrier(2)
    results = [None, None]
    def attempt(index: int, customer: str):
        barrier.wait()
        results[index] = (customer, *request_json("POST", endpoint, {"customer": customer}))
    threads = [threading.Thread(target=attempt, args=(0, "alice")), threading.Thread(target=attempt, args=(1, "bob"))]
    for thread in threads:
        thread.start()
    for thread in threads:
        thread.join()
    for customer, status, body in results:
        print(f"{customer}: HTTP {status} {body}")


if __name__ == "__main__":
    if len(sys.argv) != 2:
        raise SystemExit("Usage: python app/race_test.py naive")
    run_race(sys.argv[1])

How are the requests synchronized?

  • The reset request restores A1 before each race.
  • The barrier releases both buyer threads only after both have reached the same point.
  • Each thread sends its customer name to the same naive endpoint.
  • The result loop prints the status returned to each buyer.
  • Save app/race_test.py.
  • Rebuild the API image with the new test script by running:
docker compose up --build -d

Why is the rebuild required?

The race script must exist inside the API container before docker compose exec can run it. The rebuild copies the new file into that container.

Before you run the race, predict whether both buyers can receive a confirmation for the same available seat.

  • Release both buyers against the naive route by running:
docker compose exec api python app/race_test.py naive

The failure is intentional

You should see one result for alice and one for bob. Both results show HTTP 200 confirmations for seat A1.

Both requests read available during the one-second window. The later update overwrites the earlier owner even though both callers were told they succeeded.

The race is now visible. Two customers received confirmation for one seat.

  • In app/race_test.py, place the cursor after the loop that prints each buyer result.
  • Add the final inventory report before the script entry point:
    inventory = get_inventory()
    seat = next(item for item in inventory["seats"] if item["seat_id"] == SEAT_ID)
    successes = sum(1 for _, status, _ in results if status == 200)
    print(f"Final seat: status={seat['status']}, booked_by={seat['booked_by']}, booking_count={seat['booking_count']}")
    print(f"Invariant broken: {successes} confirmations were issued for 1 seat.")

What does the final report prove?

  • The inventory lookup reveals which customer's update remained on the seat.
  • The success count records how many confirmations the API issued.
  • The booking count exposes that the unsafe route created no immutable booking record.

✔️ Awesome, I've got everything!

Your test now reports the two HTTP results plus the final database state. Save app/race_test.py before rebuilding.

ⓧ I'd like to double check the full code

import json
import sys
import threading
import urllib.error
import urllib.request

BASE_URL = "http://127.0.0.1:8000"
SHOW_ID = 1
SEAT_ID = "A1"


def request_json(method: str, path: str, payload=None):
    data = None
    headers = {}
    if payload is not None:
        data = json.dumps(payload).encode("utf-8")
        headers["Content-Type"] = "application/json"
    request = urllib.request.Request(f"{BASE_URL}{path}", data=data, headers=headers, method=method)
    try:
        with urllib.request.urlopen(request, timeout=10) as response:
            return response.status, json.loads(response.read())
    except urllib.error.HTTPError as error:
        return error.code, json.loads(error.read())


def get_inventory():
    with urllib.request.urlopen(f"{BASE_URL}/shows/{SHOW_ID}/seats", timeout=10) as response:
        return json.loads(response.read())


def run_race(mode: str):
    if mode == "naive":
        endpoint = f"/demo/shows/{SHOW_ID}/seats/{SEAT_ID}/book-naive"
    else:
        raise SystemExit("Usage: python app/race_test.py naive")
    reset_status, reset_body = request_json("POST", f"/demo/reset/{SHOW_ID}/{SEAT_ID}")
    if reset_status != 200:
        raise RuntimeError(reset_body)
    barrier = threading.Barrier(2)
    results = [None, None]
    def attempt(index: int, customer: str):
        barrier.wait()
        results[index] = (customer, *request_json("POST", endpoint, {"customer": customer}))
    threads = [threading.Thread(target=attempt, args=(0, "alice")), threading.Thread(target=attempt, args=(1, "bob"))]
    for thread in threads:
        thread.start()
    for thread in threads:
        thread.join()
    for customer, status, body in results:
        print(f"{customer}: HTTP {status} {body}")
    inventory = get_inventory()
    seat = next(item for item in inventory["seats"] if item["seat_id"] == SEAT_ID)
    successes = sum(1 for _, status, _ in results if status == 200)
    print(f"Final seat: status={seat['status']}, booked_by={seat['booked_by']}, booking_count={seat['booking_count']}")
    print(f"Invariant broken: {successes} confirmations were issued for 1 seat.")


if __name__ == "__main__":
    if len(sys.argv) != 2:
        raise SystemExit("Usage: python app/race_test.py naive")
    run_race(sys.argv[1])

How to use this reference

Compare the helper order, thread barrier, buyer names, result reporting, and script entry point with your saved file. The complete script resets the seat every time you run it.

  • Save app/race_test.py.
  • Rebuild the API image with the completed report by running:
docker compose up --build -d

What changes in the container?

The rebuilt API image now contains the completed concurrency script. Both services remain running after the detached startup finishes.

Before you rerun the test, predict how the two confirmations compare with the one final seat owner and the booking count.

  • Run the completed naive race by using:
docker compose exec api python app/race_test.py naive

What should I see?

You should see two HTTP 200 confirmations. The final line reports one booked_by owner with booking_count=0.

The output finishes by stating that two confirmations were issued for one seat. This is the broken invariant the next design must protect.

Does the race produce different output?

  • Check that time.sleep(1) remains between the availability check and the update.
  • Check that both threads call the same naive endpoint for A1.
  • Confirm that the barrier waits for exactly two threads.

Still stuck? Help me debug why my synchronized naive booking test does not produce two HTTP 200 confirmations.

You have turned a hidden timing bug into a repeatable demonstration. Next, you'll protect the seat state and run the same pressure test against the safer path.

Lock the Seat Transaction

That was the hard proof to capture. Your FastAPI test exposed a race condition that confirmed seat A1 twice.

The next goal is one confirmed booking under the same pressure. A PostgreSQL transaction uses pessimistic row locking to make the competing request wait before it rechecks the seat.

In this step, get ready to:
  • Add a safe booking route that locks the target seat row.
  • Record the successful booking inside the protected transaction.
  • Prove the invariant with two concurrent booking requests.
Protect the seat row

A row lock keeps the availability check close to the state change it protects. The existing fetch_seat() helper already appends FOR UPDATE when lock=True.

  • Switch back to app/main.py in Visual Studio Code.
  • Place your cursor on a blank line below book_seat_naive().
  • Add the locking portion of book_seat() with this code:
@app.post("/shows/{show_id}/seats/{seat_id}/book")
def book_seat(show_id: int, seat_id: str, request: BookingRequest):
    with psycopg.connect(DB_DSN) as conn:
        seat = fetch_seat(conn, show_id, seat_id, lock=True)
        if seat is None:
            raise HTTPException(status_code=404, detail="Seat not found")
        if seat[0] != "available":
            raise HTTPException(status_code=409, detail="Seat already booked")

        time.sleep(1)

        conn.execute(
            """
            UPDATE show_seats
            SET status = 'booked', booked_by = %s
            WHERE show_id = %s AND seat_id = %s
            """,
            (request.customer, show_id, seat_id),
        )

How Does the Lock Protect the Seat?

  • The lock=True argument makes fetch_seat() append FOR UPDATE to its query.
  • The first request locks the matching show_seats row for the lifetime of its transaction.
  • The second request waits for that lock before it reads the current status.
  • The one-second delay keeps the same pressure that exposed the naive route.
  • Continue book_seat() directly after the update block with this code:
        booking = conn.execute(
            """
            INSERT INTO bookings (show_id, seat_id, customer)
            VALUES (%s, %s, %s)
            RETURNING show_id, seat_id, customer, booked_at
            """,
            (show_id, seat_id, request.customer),
        ).fetchone()

    return {
        "message": "Booking confirmed",
        "show_id": booking[0],
        "seat_id": booking[1],
        "customer": booking[2],
        "booked_at": booking[3],
    }

What Completes the Transaction?

The bookings insert runs while the seat lock is still held. Its composite primary key provides a database-level uniqueness backstop for (show_id, seat_id).

The Psycopg connection context commits both writes when the block succeeds. An exception causes the transaction to roll back.

  • Save app/main.py.
  • Rebuild the running Docker Compose stack by running this command:
docker compose up --build --wait

What Does This Command Do?

This rebuilds the API image with the updated app/main.py. It waits for the database health check before confirming that the services are running.

The stack should finish with healthy db and running api services. Your safe booking route is now available inside the rebuilt API container.

Safe Route Not Loading?

  • Check that book_seat() sits below book_seat_naive() without being nested inside it.
  • Confirm the booking insert remains inside the with psycopg.connect(DB_DSN) as conn: block.
  • Compare every closing parenthesis with the completed file below.

Still stuck? Help me debug the safe booking route in app/main.py

✔️ Awesome, I've got everything!

Your safe route now locks the seat before updating it. Double-check that you saved app/main.py.

ⓧ I'd like to double check the full code

import time

import psycopg
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

DB_DSN = "dbname=cinema user=cinema password=cinema host=db"

app = FastAPI(title="Cinema Seat Booking API")


class BookingRequest(BaseModel):
    customer: str


@app.get("/")
def health():
    return {"service": "cinema-booking", "status": "ok"}


def fetch_seat(conn, show_id: int, seat_id: str, lock: bool = False):
    query = """
        SELECT status, booked_by
        FROM show_seats
        WHERE show_id = %s AND seat_id = %s
    """
    if lock:
        query += " FOR UPDATE"
    return conn.execute(query, (show_id, seat_id)).fetchone()


@app.get("/shows/{show_id}/seats")
def list_seats(show_id: int):
    with psycopg.connect(DB_DSN) as conn:
        rows = conn.execute(
            """
            SELECT
                s.movie_title,
                s.starts_at::text,
                ss.seat_id,
                ss.status,
                ss.booked_by,
                (
                    SELECT COUNT(*)
                    FROM bookings AS b
                    WHERE b.show_id = ss.show_id
                      AND b.seat_id = ss.seat_id
                ) AS booking_count
            FROM shows AS s
            JOIN show_seats AS ss ON ss.show_id = s.id
            WHERE s.id = %s
            ORDER BY ss.seat_id
            """,
            (show_id,),
        ).fetchall()

    if not rows:
        raise HTTPException(status_code=404, detail="Show not found")

    return {
        "show_id": show_id,
        "movie_title": rows[0][0],
        "starts_at": rows[0][1],
        "seats": [
            {
                "seat_id": row[2],
                "status": row[3],
                "booked_by": row[4],
                "booking_count": row[5],
            }
            for row in rows
        ],
    }


@app.post("/demo/reset/{show_id}/{seat_id}")
def reset_seat(show_id: int, seat_id: str):
    with psycopg.connect(DB_DSN) as conn:
        deleted = conn.execute(
            """
            DELETE FROM bookings
            WHERE show_id = %s AND seat_id = %s
            RETURNING seat_id
            """,
            (show_id, seat_id),
        ).fetchall()
        seat = conn.execute(
            """
            UPDATE show_seats
            SET status = 'available', booked_by = NULL
            WHERE show_id = %s AND seat_id = %s
            RETURNING seat_id
            """,
            (show_id, seat_id),
        ).fetchone()

    if seat is None:
        raise HTTPException(status_code=404, detail="Seat not found")

    return {
        "seat_id": seat_id,
        "status": "available",
        "deleted_bookings": len(deleted),
    }


@app.post("/demo/shows/{show_id}/seats/{seat_id}/book-naive")
def book_seat_naive(show_id: int, seat_id: str, request: BookingRequest):
    with psycopg.connect(DB_DSN) as conn:
        seat = fetch_seat(conn, show_id, seat_id)
        if seat is None:
            raise HTTPException(status_code=404, detail="Seat not found")
        if seat[0] != "available":
            raise HTTPException(status_code=409, detail="Seat already booked")

        time.sleep(1)

        conn.execute(
            """
            UPDATE show_seats
            SET status = 'booked', booked_by = %s
            WHERE show_id = %s AND seat_id = %s
            """,
            (request.customer, show_id, seat_id),
        )

    return {
        "message": "Booking confirmed by unsafe demo path",
        "show_id": show_id,
        "seat_id": seat_id,
        "customer": request.customer,
    }


@app.post("/shows/{show_id}/seats/{seat_id}/book")
def book_seat(show_id: int, seat_id: str, request: BookingRequest):
    with psycopg.connect(DB_DSN) as conn:
        seat = fetch_seat(conn, show_id, seat_id, lock=True)
        if seat is None:
            raise HTTPException(status_code=404, detail="Seat not found")
        if seat[0] != "available":
            raise HTTPException(status_code=409, detail="Seat already booked")

        time.sleep(1)

        conn.execute(
            """
            UPDATE show_seats
            SET status = 'booked', booked_by = %s
            WHERE show_id = %s AND seat_id = %s
            """,
            (request.customer, show_id, seat_id),
        )
        booking = conn.execute(
            """
            INSERT INTO bookings (show_id, seat_id, customer)
            VALUES (%s, %s, %s)
            RETURNING show_id, seat_id, customer, booked_at
            """,
            (show_id, seat_id, request.customer),
        ).fetchone()

    return {
        "message": "Booking confirmed",
        "show_id": booking[0],
        "seat_id": booking[1],
        "customer": booking[2],
        "booked_at": booking[3],
    }

Use this full file to compare the route placement, transaction indentation, SQL statements, and response fields.

Add safe mode to the race test

The same two synchronized buyers now need to target the production booking route. Keeping the barrier and one-second delay unchanged makes the comparison fair.

  • Switch back to app/race_test.py in Visual Studio Code.
  • Find the mode selection block inside run_race() that ends with raise SystemExit("Usage: python app/race_test.py naive").
  • Replace that mode selection block with this code:
    if mode == "naive":
        endpoint = f"/demo/shows/{SHOW_ID}/seats/{SEAT_ID}/book-naive"
    elif mode == "safe":
        endpoint = f"/shows/{SHOW_ID}/seats/{SEAT_ID}/book"
    else:
        raise SystemExit("Usage: python app/race_test.py naive|safe")

How Does Safe Mode Choose the Route?

The new branch maps safe mode to the locked booking endpoint. The existing naive branch remains available for comparison.

  • Find the final raise SystemExit("Usage: python app/race_test.py naive") line at the bottom of app/race_test.py.
  • Replace that line with this code:
        raise SystemExit("Usage: python app/race_test.py naive|safe")

Why Update the Usage Message?

The message now lists both accepted modes when the script receives a missing or unsupported argument. This keeps the command guidance aligned with run_race().

  • Save app/race_test.py.
  • Rebuild the API image with the updated script by running this command:
docker compose up --build --wait

Why Rebuild Again?

The Dockerfile copies the app folder into the API image. Rebuilding places the updated race script inside the running container.

Before you run the test, decide whether both buyers can still receive a successful response.

  • Send both buyers through the locked route by running this command:
docker compose exec api python app/race_test.py safe

What Does the First Safe Run Prove?

  • One buyer receives HTTP 200 because that request locks the available seat first.
  • The competing buyer receives HTTP 409 after the lock releases and the route rechecks the updated status.
  • The final inventory shows booking_count=1 because the successful transaction inserted one booking row.
  • The final script summary still uses the naive wording because its reporting branch has not been updated yet.

Safe Mode Not Recognized?

  • Confirm both usage messages include naive|safe.
  • Check that the elif mode == "safe": branch sits before the final else: branch.
  • Rebuild the image after saving the local file.

Need another pair of eyes? Help me debug safe mode in app/race_test.py

Report the protected invariant

The HTTP responses already prove that the lock works. The final report now needs to count conflicts and describe the safe result accurately.

  • Find the reporting section in run_race() that starts with successes = sum(1 for _, status, _ in results if status == 200).
  • Replace that reporting section with this code:
    successes = sum(1 for _, status, _ in results if status == 200)
    conflicts = sum(1 for _, status, _ in results if status == 409)

    print(
        "Final seat: "
        f"status={seat['status']}, "
        f"booked_by={seat['booked_by']}, "
        f"booking_count={seat['booking_count']}"
    )

    if mode == "naive":
        print(f"Invariant broken: {successes} confirmations were issued for 1 seat.")
    else:
        print(
            "Invariant protected: "
            f"{successes} confirmation, {conflicts} conflict, "
            f"and {seat['booking_count']} booking row."
        )

What Does the Final Report Measure?

  • The successes count measures confirmed requests.
  • The conflicts count measures requests rejected after the locked status check.
  • The inventory supplies the final booking row count from the database.
  • Separate summaries preserve the broken naive demonstration and the protected safe result.
  • Save app/race_test.py.
  • Rebuild the API image with the final report by running this command:
docker compose up --build --wait

What Is Ready to Test?

The rebuilt container now contains the safe route and the completed concurrency report. The reset route clears A1 before each run so the result remains repeatable.

Before the final run, predict the three counters in the protected summary.

  • Verify the protected invariant by running this command:
docker compose exec api python app/race_test.py safe

What Should You See?

You should see one HTTP 200 response and one HTTP 409 response. The order of alice and bob can change between runs.

The final seat should show booking_count=1. The summary should report one confirmation and one conflict.

Seeing Two Successes or No Booking Row?

  • Confirm book_seat() calls fetch_seat(conn, show_id, seat_id, lock=True).
  • Check that the booking insert remains inside the same connection context as the locked read.
  • Rebuild the image after saving both Python files.

Still seeing the wrong result? Help me trace the safe concurrency test

✔️ Awesome, I've got everything!

Your concurrency script now proves the booking invariant under contention. Double-check that you saved app/race_test.py.

ⓧ I'd like to double check the full code

import json
import sys
import threading
import urllib.error
import urllib.request

BASE_URL = "http://127.0.0.1:8000"
SHOW_ID = 1
SEAT_ID = "A1"


def request_json(method: str, path: str, payload=None):
    data = None
    headers = {}
    if payload is not None:
        data = json.dumps(payload).encode("utf-8")
        headers["Content-Type"] = "application/json"

    request = urllib.request.Request(
        f"{BASE_URL}{path}",
        data=data,
        headers=headers,
        method=method,
    )

    try:
        with urllib.request.urlopen(request, timeout=10) as response:
            return response.status, json.loads(response.read())
    except urllib.error.HTTPError as error:
        return error.code, json.loads(error.read())


def get_inventory():
    with urllib.request.urlopen(
        f"{BASE_URL}/shows/{SHOW_ID}/seats",
        timeout=10,
    ) as response:
        return json.loads(response.read())


def run_race(mode: str):
    if mode == "naive":
        endpoint = f"/demo/shows/{SHOW_ID}/seats/{SEAT_ID}/book-naive"
    elif mode == "safe":
        endpoint = f"/shows/{SHOW_ID}/seats/{SEAT_ID}/book"
    else:
        raise SystemExit("Usage: python app/race_test.py naive|safe")

    reset_status, reset_body = request_json(
        "POST",
        f"/demo/reset/{SHOW_ID}/{SEAT_ID}",
    )
    if reset_status != 200:
        raise RuntimeError(reset_body)

    barrier = threading.Barrier(2)
    results = [None, None]

    def attempt(index: int, customer: str):
        barrier.wait()
        status, body = request_json(
            "POST",
            endpoint,
            {"customer": customer},
        )
        results[index] = (customer, status, body)

    threads = [
        threading.Thread(target=attempt, args=(0, "alice")),
        threading.Thread(target=attempt, args=(1, "bob")),
    ]

    for thread in threads:
        thread.start()
    for thread in threads:
        thread.join()

    for customer, status, body in results:
        print(f"{customer}: HTTP {status} {body}")

    inventory = get_inventory()
    seat = next(item for item in inventory["seats"] if item["seat_id"] == SEAT_ID)
    successes = sum(1 for _, status, _ in results if status == 200)
    conflicts = sum(1 for _, status, _ in results if status == 409)

    print(
        "Final seat: "
        f"status={seat['status']}, "
        f"booked_by={seat['booked_by']}, "
        f"booking_count={seat['booking_count']}"
    )

    if mode == "naive":
        print(f"Invariant broken: {successes} confirmations were issued for 1 seat.")
    else:
        print(
            "Invariant protected: "
            f"{successes} confirmation, {conflicts} conflict, "
            f"and {seat['booking_count']} booking row."
        )


if __name__ == "__main__":
    if len(sys.argv) != 2:
        raise SystemExit("Usage: python app/race_test.py naive|safe")
    run_race(sys.argv[1])

Use this full file to compare the mode selection, synchronized requests, result counters, and final summary.

You have turned the broken race into a repeatable proof of correctness. Next, you will connect this transaction boundary to an interview-ready architecture story.

Document the System Design

Your safe concurrency test now proves that PostgreSQL admits one booking while rejecting the competing request. The running code gives you concrete evidence for the design.

Interviewers also need to hear how the invariant extends through the transaction boundary into a scalable architecture. In this step, you'll capture that story in SYSTEM_DESIGN.md inside Visual Studio Code.

In this step, get ready to:
  • Capture the service contract from requirements through storage.
  • Explain why row locking protects the booking invariant.
  • Turn scaling choices into a 90-second interview walkthrough.
Define the system contract

Your design document starts with the promises the service makes. These statements keep the architecture tied to behavior you can prove.

  • Click the New File button beside the folder that contains compose.yaml.
  • Enter SYSTEM_DESIGN.md as the file name.

You'll see an empty SYSTEM_DESIGN.md editor tab beside the existing project files.

  • Add the requirements plus the core invariant by pasting this section:
# Cinema Seat Booking System Design

## 1. Requirements and invariant

Functional requirements:

- List seats for a show.
- Book one seat for one customer.
- Reject a booking when that seat is no longer available.

Non-functional requirements:

- Prefer correctness over write throughput for the booking hot path.
- Keep the API layer stateless so replicas can be added.
- Keep transactions short and observable.

Invariant: For each `(show_id, seat_id)`, at most one booking may succeed.

What does this section establish?

  • Functional requirements define the behavior a client can use.
  • Non-functional requirements define the priorities that shape the implementation.
  • The invariant gives every later design choice one correctness test.
  • Save SYSTEM_DESIGN.md.
  • Press ⌘K V to open the rendered preview.

You'll see the document title followed by the two requirement groups. The invariant appears below them as the central design rule.

Missing the rendered headings?

Check that the file name ends in .md. Confirm that each heading starts with the same number of hash characters shown in the snippet.

Still stuck? Help me fix the heading structure in my system design document.

  • Switch back to the SYSTEM_DESIGN.md editor tab.
  • Append the API contract after the invariant by pasting this section:
## 2. API contract

- `GET /shows/{show_id}/seats` returns the current seat map.
- `POST /shows/{show_id}/seats/{seat_id}/book` confirms one available seat.
- A successful booking returns HTTP 200.
- A request for an already booked seat returns HTTP 409.

What does the API contract capture?

  • The REST API contract identifies the read path for seat inventory.
  • The booking path identifies the protected write operation.
  • The response codes give callers distinct outcomes for success or conflict.
  • Save SYSTEM_DESIGN.md.
  • Press ⌘K V to refresh the rendered preview.

You'll see the inventory endpoint followed by the booking endpoint. The two HTTP outcomes appear directly beneath them.

Is the API section inside a list?

Check for a blank line before the API heading. Make sure each contract entry begins with one hyphen.

Still stuck? Help me correct the Markdown list in my API contract.

  • Switch back to the SYSTEM_DESIGN.md editor tab.
  • Append the data model after the HTTP conflict rule by pasting this section:
## 3. Data model

- `shows` stores event identity, title, and start time.
- `show_seats` stores current state for every seat and show.
- `bookings` stores the immutable successful booking record.
- Composite primary keys scope a seat to a show.
- The `bookings` primary key supplies a database-level uniqueness backstop.

Why describe all three tables?

  • The shows table owns event details.
  • The show_seats table owns current seat state.
  • The bookings table preserves the successful booking record.
  • The primary key remains a final uniqueness backstop.
  • Save SYSTEM_DESIGN.md.
  • Press ⌘K V to refresh the rendered preview.

You'll see five data model points beneath the API contract. The final point connects the database constraint to the invariant.

Are the table names plain text?

Check that each table name has one backtick on each side. Backticks make project identifiers stand out in the rendered document.

Still stuck? Help me fix the inline code formatting in my data model section.

Explain concurrency and scale

A Mermaid diagram gives the design a visible request path. The sections beneath it connect the local transaction to horizontal scaling.

  • Switch back to the SYSTEM_DESIGN.md editor tab.
  • Append the architecture after the data model by pasting this section:
## 4. Architecture

```mermaid
flowchart LR
    Client[Web or mobile client] --> Gateway[Load balancer or API gateway]
    Gateway --> API[Stateless booking API replicas]
    API --> Primary[(PostgreSQL primary)]
    Primary --> Replica[(Read replicas for non-critical reads)]
    API -. optional seat-map cache .-> Cache[(Distributed cache)]
    API -. events after commit .-> Queue[Event queue]
    Queue --> Worker[Notification and analytics workers]
```

What does the architecture show?

  • Stateless API replicas can share the request load.
  • The PostgreSQL primary remains authoritative for booking writes.
  • A distributed cache can accelerate advisory seat-map reads.
  • An event queue moves notification work outside the booking transaction.
  • Save SYSTEM_DESIGN.md.
  • Press ⌘K V to refresh the rendered preview.

You'll see a left-to-right flowchart from the client to the API replicas. Separate paths show the database primary, cached reads, plus event processing.

Seeing Mermaid text instead of a diagram?

Confirm that the opening Mermaid fence matches the snippet. Check that the closing fence appears on its own line.

Still stuck? Help me troubleshoot the Mermaid diagram in Visual Studio Code.

  • Switch back to the SYSTEM_DESIGN.md editor tab.
  • Append the concurrency decision after the Mermaid diagram by pasting this section:
## 5. Concurrency decision

The naive path performs read, check, delay, and write during overlapping transactions. Both requests can read `available` before either writes, so both clients receive confirmation.

The safe path selects the target row with `FOR UPDATE`, rechecks state, updates the seat, inserts a booking, and commits as one transaction. Requests for different seats can proceed independently, while requests for the same seat serialize on that row.

Why is this the core interview explanation?

  • The first paragraph turns the observed race condition into a precise read-check-write failure.
  • The second paragraph explains how pessimistic row locking protects one seat.
  • The lock scope preserves concurrency for requests targeting different seats.
  • Save SYSTEM_DESIGN.md.
  • Press ⌘K V to refresh the rendered preview.

You'll see the failed design followed by the protected transaction. The contrast matches the naive and safe demonstrations you already ran.

Does the transaction explanation look incomplete?

Compare both paragraphs with the snippet. Make sure the safe path includes the lock, state recheck, seat update, booking insert, plus commit.

Still stuck? Help me check my concurrency decision section for missing details.

  • Switch back to the SYSTEM_DESIGN.md editor tab.
  • Append the scale path after the concurrency decision by pasting this section:
## 6. Scale path

1. Add stateless API replicas behind a load balancer.
2. Cache read-heavy seat maps, but validate booking writes against the PostgreSQL primary.
3. Partition high-volume data by `show_id` or event key.
4. Publish notification and analytics work after booking commits.
5. Monitor lock wait time, conflict rate, database connections, and booking latency.

The likely hotspot is a popular show or seat. Lock only the selected seat and avoid network calls while the lock is held.

How does this design scale?

  • Stateless replicas scale request handling without changing booking authority.
  • Cached availability improves read capacity while the primary validates writes.
  • A hot row forms when many customers compete for the same seat.
  • Lock metrics reveal contention before it becomes a latency problem.
  • Save SYSTEM_DESIGN.md.
  • Press ⌘K V to refresh the rendered preview.

You'll see five numbered scaling decisions. A final paragraph identifies the likely hotspot plus the rule for keeping the lock narrow.

Is the scale path missing numbers?

Check that each scaling decision begins with its number followed by a full stop. Keep a blank line before the hotspot paragraph.

Still stuck? Help me repair the numbered scale path in my Markdown preview.

Harden and rehearse the design

Production design includes recovery from ambiguous outcomes. Idempotency gives a retry a stable result after a client loses the original response.

  • Switch back to the SYSTEM_DESIGN.md editor tab.
  • Append the failure modes after the scale path by pasting this section:
## 7. Failure modes and hardening

- Client timeout after commit: add an idempotency key so a retry returns the original result.
- Database unavailable: fail rather than claim success.
- Long lock waits: keep transactions short and surface contention metrics.
- Stale cached availability: treat cache data as advisory and recheck the primary.
- Duplicate write path: retain the booking primary key as a backstop.

What does hardening add?

  • Idempotency protects retries after an uncertain client outcome.
  • Primary database checks prevent stale cached data from approving a booking.
  • Contention metrics add observability around lock pressure.
  • The booking primary key protects against duplicate writes.
  • Save SYSTEM_DESIGN.md.
  • Press ⌘K V to refresh the rendered preview.

You'll see five failure modes with a matching response for each one. The list covers retries, availability, contention, stale reads, plus duplicate writes.

Are the failure responses hard to scan?

Check that each failure mode starts on its own line. Keep the colon between the failure and its response.

Still stuck? Help me format the failure modes as a readable Markdown list.

  • Switch back to the SYSTEM_DESIGN.md editor tab.
  • Append the interview walkthrough after the failure modes by pasting this section:
## 8. 90-second walkthrough

1. State the invariant: one successful booking per show and seat.
2. Define the read and booking APIs.
3. Explain the `shows`, `show_seats`, and `bookings` tables.
4. Demonstrate why read-check-write races.
5. Place `SELECT ... FOR UPDATE`, the seat update, and booking insert in one transaction.
6. Identify hot rows and connection pressure as first bottlenecks.
7. Scale reads separately, retain the primary for writes, and add idempotency next.

How does the walkthrough flow?

  • The opening establishes the invariant plus the external contract.
  • The middle connects the naive failure to the protected transaction.
  • The close identifies the first bottlenecks plus the next hardening step.
  • Save SYSTEM_DESIGN.md.
  • Press ⌘K V to refresh the rendered preview.

You'll see a seven-part walkthrough at the bottom of the document. Its order carries the story from correctness through scale.

Is the walkthrough out of order?

Compare the seven numbered points with the snippet. Check that the invariant opens the story while idempotency closes it.

Still stuck? Help me restore the order of my 90-second walkthrough.

✔️ Awesome, I've got everything!

Your system design document now contains all eight sections. The rendered preview is ready for the final check.

ⓧ I'd like to double check the full code

# Cinema Seat Booking System Design

## 1. Requirements and invariant

Functional requirements:

- List seats for a show.
- Book one seat for one customer.
- Reject a booking when that seat is no longer available.

Non-functional requirements:

- Prefer correctness over write throughput for the booking hot path.
- Keep the API layer stateless so replicas can be added.
- Keep transactions short and observable.

Invariant: For each `(show_id, seat_id)`, at most one booking may succeed.

## 2. API contract

- `GET /shows/{show_id}/seats` returns the current seat map.
- `POST /shows/{show_id}/seats/{seat_id}/book` confirms one available seat.
- A successful booking returns HTTP 200.
- A request for an already booked seat returns HTTP 409.

## 3. Data model

- `shows` stores event identity, title, and start time.
- `show_seats` stores current state for every seat and show.
- `bookings` stores the immutable successful booking record.
- Composite primary keys scope a seat to a show.
- The `bookings` primary key supplies a database-level uniqueness backstop.

## 4. Architecture

```mermaid
flowchart LR
    Client[Web or mobile client] --> Gateway[Load balancer or API gateway]
    Gateway --> API[Stateless booking API replicas]
    API --> Primary[(PostgreSQL primary)]
    Primary --> Replica[(Read replicas for non-critical reads)]
    API -. optional seat-map cache .-> Cache[(Distributed cache)]
    API -. events after commit .-> Queue[Event queue]
    Queue --> Worker[Notification and analytics workers]
```

## 5. Concurrency decision

The naive path performs read, check, delay, and write during overlapping transactions. Both requests can read `available` before either writes, so both clients receive confirmation.

The safe path selects the target row with `FOR UPDATE`, rechecks state, updates the seat, inserts a booking, and commits as one transaction. Requests for different seats can proceed independently, while requests for the same seat serialize on that row.

## 6. Scale path

1. Add stateless API replicas behind a load balancer.
2. Cache read-heavy seat maps, but validate booking writes against the PostgreSQL primary.
3. Partition high-volume data by `show_id` or event key.
4. Publish notification and analytics work after booking commits.
5. Monitor lock wait time, conflict rate, database connections, and booking latency.

The likely hotspot is a popular show or seat. Lock only the selected seat and avoid network calls while the lock is held.

## 7. Failure modes and hardening

- Client timeout after commit: add an idempotency key so a retry returns the original result.
- Database unavailable: fail rather than claim success.
- Long lock waits: keep transactions short and surface contention metrics.
- Stale cached availability: treat cache data as advisory and recheck the primary.
- Duplicate write path: retain the booking primary key as a backstop.

## 8. 90-second walkthrough

1. State the invariant: one successful booking per show and seat.
2. Define the read and booking APIs.
3. Explain the `shows`, `show_seats`, and `bookings` tables.
4. Demonstrate why read-check-write races.
5. Place `SELECT ... FOR UPDATE`, the seat update, and booking insert in one transaction.
6. Identify hot rows and connection pressure as first bottlenecks.
7. Scale reads separately, retain the primary for writes, and add idempotency next.

Before the final check, do you expect the preview to read as one connected story from invariant to scaling?

  • Save SYSTEM_DESIGN.md.
  • Press ⌘K V in Visual Studio Code to open the rendered preview.

You'll see the Mermaid architecture plus sections for requirements, APIs, schema, concurrency, scaling, failure modes, plus the 90-second walkthrough. The document connects the naive race to the protected transaction.

  • Set a 90-second timer.
  • Rehearse section 8 aloud.

You should finish with the hot-row bottleneck plus idempotency as the next production hardening step.

Does the final preview look incomplete?

Compare SYSTEM_DESIGN.md with the full-file reference. Check for a missing section heading or an unclosed Mermaid fence.

Still stuck? Help me compare my complete system design document with the expected structure.

That's your interview artifact ready. It connects the reproduced race to a defensible transaction boundary.

Secret mission

Design Temporary Seat Holds

Payment can take time while other customers are trying to book the same seat. Extend your system design with temporary holds that protect a seat during payment, expire safely, and make retried confirmations idempotent.

Clean Up Your Resources

Docker Desktop is free for personal use, education, non-commercial open source, and qualifying small businesses.

Larger enterprises and government entities may require a paid subscription.

Nice Work!

Nice Work!

You did it! You built a cinema booking system that protects one seat from competing requests.

You've learned how to:

  • Run a reproducible Docker Compose stack that serves a FastAPI booking API backed by PostgreSQL. Inspect the seeded seat inventory through interactive API documentation.
  • Expose a race condition with two synchronized clients. Protect the booking invariant with a transaction plus SELECT ... FOR UPDATE. Verify one successful booking plus one conflict under contention.
  • Present an interview-ready system design from SYSTEM_DESIGN.md. Explain the transaction boundary. Defend the scaling choices through a 90-second walkthrough.
  • Secret Mission: Design temporary seat holds with expiry cleanup. Use PostgreSQL database time as the expiry authority. Protect retried confirmations with an idempotency key.

Ready to quiz yourself?