Concert Ticket Booking with Go

Build a Go app that demos pessimistic and optimistic locking in PostgreSQL.

Introduction

30 Second Summary

When thousands of fans rush to buy the last concert ticket at exactly the same moment, something has to prevent two people from both getting it. This is the concurrency problem that every booking system, payment processor, and inventory manager must solve.

In this project, you will build a Go application that simulates 10 buyers racing for the same concert seat. You'll implement two different locking strategies against a live PostgreSQL database and watch them handle the race in real time.

What You'll Build

A concert ticket booking simulator that demonstrates both pessimistic and optimistic concurrency control against PostgreSQL running in a Podman container.

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

  • A PostgreSQL 17 database running in a Podman container with a tickets schema and seed data.
  • A Go application implementing pessimistic locking with SELECT FOR UPDATE that serializes concurrent access to a row.
  • An optimistic locking strategy using a version column that detects conflicts at write time and retries automatically.
  • A timed comparison showing how each strategy behaves under contention from 10 concurrent goroutines.
  • Secret Mission: Implement FOR UPDATE SKIP LOCKED for parallel, non-blocking seat assignment.

Are there any prerequisites?

This project assumes basic familiarity with SQL (SELECT, UPDATE, INSERT) and PostgreSQL. You should also be comfortable reading and writing basic Go code.

You do not need prior experience with concurrency control, locking strategies, or goroutines. The project teaches these from the ground up.

Set Up PostgreSQL and Initialize the Go Project

Before you can simulate 10 buyers racing for the same concert ticket, you need a live database to hold the tickets and a Go application to create the race.

In this step, you will start a PostgreSQL 17 container using Podman, create a Go project folder, and write the initial code that connects to the database.

In this step, get ready to:
  • Start a PostgreSQL 17 container with Podman.
  • Initialize a Go project and install the pgx database driver.
  • Verify the connection between your Go app and PostgreSQL.
Start a PostgreSQL container with Podman

Your Go application needs a database to store ticket and booking data. You will run PostgreSQL inside a container so it stays isolated from the rest of your system.

  • Start a PostgreSQL 17 container by running this command in your terminal:
podman run -d --name concert-db \
  -e POSTGRES_PASSWORD=secret \
  -e POSTGRES_DB=concertdb \
  -p 5432:5432 \
  docker.io/library/postgres:17

What does this command do?

  • podman run -d starts a container in the background.
  • --name concert-db gives the container a name you can reference later.
  • POSTGRES_PASSWORD=secret and POSTGRES_DB=concertdb configure the database credentials and create a database called concertdb.
  • -p 5432:5432 maps the container's PostgreSQL port to your machine so your Go app can connect on localhost:5432.
  • Verify the container is running:
podman ps

You should see a row with concert-db in the NAMES column and a status of "Up".

Don't see the container running?

  • If you see an error about the Podman machine not running, run podman machine start first, then try the podman run command again.
  • If port 5432 is already in use, you may have another PostgreSQL instance running. Stop it first or change the host port to -p 5433:5432.
  • help me troubleshoot my Podman container not starting
Initialize the Go project and install pgx

Every Go project starts with a module. The module tracks your project's name and its dependencies. You will also install pgx, the most popular Go driver for PostgreSQL.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir concurrency-demo && cd concurrency-demo
  • Confirm the folder exists by running:
ls ~/Desktop

You should see concurrency-demo in the output.

  • Initialize the Go module and install the pgx driver by running these commands:
go mod init concurrency-demo
go get github.com/jackc/pgx/v5

What do these commands do?

  • go mod init concurrency-demo creates a go.mod file that declares your project's module name and Go version.
  • go get github.com/jackc/pgx/v5 downloads the pgx library and adds it to your go.mod as a dependency.

You should see download output ending with no errors. A go.sum file also appears in your folder.

Seeing a network error during go get?

  • Make sure you have an internet connection. The go get command downloads packages from the Go module proxy.
  • If you see a timeout, try running the command again. Temporary network issues can cause this.
  • help me fix a go get download error
Verify Go version and connectivity

Before writing any application code, confirm your Go version meets the minimum requirement for pgx.

  • Check your Go version by running:
go version

You should see go1.25 or higher in the output.

Now you will write the initial connection code to confirm your Go app can reach the database.

  • Create a file called main.go in your concurrency-demo folder, then paste the following code into it:
package main

import (
	"context"
	"fmt"

	"github.com/jackc/pgx/v5/pgxpool"
)

// Connection string for the local PostgreSQL container
const connString = "postgres://postgres:secret@localhost:5432/concertdb?sslmode=disable"

func main() {
	ctx := context.Background()

	// Create a connection pool to the database
	pool, err := pgxpool.New(ctx, connString)
	if err != nil {
		fmt.Printf("Unable to connect to database: %v\n", err)
		return
	}
	defer pool.Close()

	// Verify the connection is alive
	err = pool.Ping(ctx)
	if err != nil {
		fmt.Printf("Unable to ping database: %v\n", err)
		return
	}
	fmt.Println("Connected to PostgreSQL!")
}

What does this code do?

  • connString holds the full URL to your containerized database, including the username, password, host, port, and database name.
  • pgxpool.New() creates a connection pool. A pool manages multiple database connections so concurrent goroutines can share them efficiently.
  • pool.Ping() sends a quick check to confirm the database is reachable. If this succeeds, your setup is correct.
  • Save main.go.
  • Run the application to test the connection:
go run main.go

You should see Connected to PostgreSQL! printed in your terminal.

Seeing 'Unable to connect to database'?

  • Run podman ps to confirm the concert-db container is still running. If it stopped, restart it with podman start concert-db.
  • Make sure the connection string in your code matches exactly: postgres://postgres:secret@localhost:5432/concertdb?sslmode=disable.
  • The container may need a few seconds to fully start PostgreSQL after being created. Wait 5 seconds and try go run main.go again.
  • help me debug my Go PostgreSQL connection error

✔️ Awesome, I've got everything!

Great. Make sure you have saved main.go and that your terminal shows Connected to PostgreSQL! before moving on.

ⓧ I'd like to double check the full code

Here is the complete main.go at this point:

package main

import (
	"context"
	"fmt"

	"github.com/jackc/pgx/v5/pgxpool"
)

const connString = "postgres://postgres:secret@localhost:5432/concertdb?sslmode=disable"

func main() {
	ctx := context.Background()

	pool, err := pgxpool.New(ctx, connString)
	if err != nil {
		fmt.Printf("Unable to connect to database: %v\n", err)
		return
	}
	defer pool.Close()

	err = pool.Ping(ctx)
	if err != nil {
		fmt.Printf("Unable to ping database: %v\n", err)
		return
	}
	fmt.Println("Connected to PostgreSQL!")
}

Your database is running and your Go app can talk to it. Next up, you will create the tickets table and seed it with concert data.

Create the Database Schema and Seed Data

Your Go application can reach PostgreSQL. Now it needs somewhere to store ticket and booking data.

Every booking system needs at least two things: a record of what is available and a record of who claimed it. In this step, you will create the database tables that track concert seats and bookings, then fill them with sample data to race against.

In this step, get ready to:
  • Create a tickets table and a bookings table in PostgreSQL.
  • Seed 5 concert tickets for testing.
  • Add a helper function to reset data between demo runs.
Define the schema and seed data

Your booking simulator needs two tables. The tickets table tracks each seat's availability, owner, and a version column you will use for optimistic locking later. The bookings table logs every successful booking with the strategy that was used.

  • In main.go, add the following setupSchema function below your main() function:
func setupSchema(ctx context.Context, pool *pgxpool.Pool) {
	schema := `
	DROP TABLE IF EXISTS bookings;
	DROP TABLE IF EXISTS tickets;

	CREATE TABLE tickets (
		id SERIAL PRIMARY KEY,
		event_name TEXT NOT NULL,
		seat_number TEXT NOT NULL,
		is_booked BOOLEAN NOT NULL DEFAULT FALSE,
		booked_by TEXT,
		version INTEGER NOT NULL DEFAULT 1
	);

	CREATE TABLE bookings (
		id SERIAL PRIMARY KEY,
		ticket_id INTEGER REFERENCES tickets(id),
		buyer_name TEXT NOT NULL,
		strategy TEXT NOT NULL,
		booked_at TIMESTAMP DEFAULT NOW()
	);`

	_, err := pool.Exec(ctx, schema)
	if err != nil {
		fmt.Printf("Failed to create schema: %v\n", err)
		return
	}
	fmt.Println("Schema created successfully!")

	seed := `
	INSERT INTO tickets (event_name, seat_number) VALUES
		('Go Conference 2026', 'A1'),
		('Go Conference 2026', 'A2'),
		('Go Conference 2026', 'A3'),
		('Go Conference 2026', 'A4'),
		('Go Conference 2026', 'A5');`

	_, err = pool.Exec(ctx, seed)
	if err != nil {
		fmt.Printf("Failed to seed data: %v\n", err)
		return
	}
	fmt.Println("Seeded 5 tickets for Go Conference 2026!")
}

What does this code do?

This function handles the full database setup in one call:

  • DROP TABLE IF EXISTS clears any previous run's tables so the demo is repeatable.
  • The tickets table stores each seat with an is_booked flag, a booked_by name, and a version integer. The version column is what makes optimistic locking possible.
  • The bookings table records which buyer got which ticket and which concurrency strategy was used.
  • The INSERT statement seeds 5 tickets for seats A1 through A5. All start as unbooked with version 1.

Why a version column?

Optimistic locking works by reading a row's version number, doing work, then writing back only if the version has not changed. If another transaction sneaked in and incremented the version, the write affects 0 rows. That zero is the conflict signal.

You will see this in action in Step 4.

Add a reset helper for demo runs

Later steps run the pessimistic demo and then the optimistic demo back-to-back. Between demos, every ticket needs to return to its unbooked state so both strategies start on equal footing.

  • Add the resetTickets function below setupSchema:
func resetTickets(ctx context.Context, pool *pgxpool.Pool) {
	pool.Exec(ctx, `UPDATE tickets SET is_booked = FALSE, booked_by = NULL, version = 1`)
	pool.Exec(ctx, `DELETE FROM bookings`)
}

What does this code do?

This helper resets every ticket to unbooked with version 1 and clears the bookings table. You will call it between the pessimistic and optimistic demos so each strategy races from the same starting line.

Run and verify the schema

The functions exist but nothing calls them yet. You need to wire setupSchema into main() so it runs on startup.

  • In main.go, find this line inside main():
	fmt.Println("Connected to PostgreSQL!")
  • Add the following line directly below it (before the closing } of main):
	setupSchema(ctx, pool)
  • Save main.go.
  • Run the application:
go run main.go

You should see three lines printed in your terminal:

Connected to PostgreSQL!
Schema created successfully!
Seeded 5 tickets for Go Conference 2026!
  • Verify the data landed in PostgreSQL by running:
podman exec concert-db psql -U postgres -d concertdb -c "SELECT * FROM tickets;"

You should see a table with 5 rows. All seats show is_booked as f (false), booked_by as empty, and version as 1.

Not seeing the expected output?

  • If you see a connection error, confirm the container is still running with podman ps. If it is not listed, restart it with podman start concert-db.
  • If you see a syntax error in Go, check that setupSchema is defined outside of main() (below the closing brace of main, not inside it).
  • If the psql command says the table does not exist, make sure you ran go run main.go successfully first.

Still stuck? Help me debug my schema setup

✔️ Awesome, I've got everything!

Great. Double check you have saved main.go with all three functions: main, setupSchema, and resetTickets.

ⓧ I'd like to double check the full code

Here is the full main.go at this point:

package main

import (
	"context"
	"fmt"

	"github.com/jackc/pgx/v5/pgxpool"
)

const connString = "postgres://postgres:secret@localhost:5432/concertdb?sslmode=disable"

func main() {
	ctx := context.Background()

	pool, err := pgxpool.New(ctx, connString)
	if err != nil {
		fmt.Printf("Unable to connect to database: %v\n", err)
		return
	}
	defer pool.Close()

	err = pool.Ping(ctx)
	if err != nil {
		fmt.Printf("Unable to ping database: %v\n", err)
		return
	}
	fmt.Println("Connected to PostgreSQL!")

	setupSchema(ctx, pool)
}

func setupSchema(ctx context.Context, pool *pgxpool.Pool) {
	schema := `
	DROP TABLE IF EXISTS bookings;
	DROP TABLE IF EXISTS tickets;

	CREATE TABLE tickets (
		id SERIAL PRIMARY KEY,
		event_name TEXT NOT NULL,
		seat_number TEXT NOT NULL,
		is_booked BOOLEAN NOT NULL DEFAULT FALSE,
		booked_by TEXT,
		version INTEGER NOT NULL DEFAULT 1
	);

	CREATE TABLE bookings (
		id SERIAL PRIMARY KEY,
		ticket_id INTEGER REFERENCES tickets(id),
		buyer_name TEXT NOT NULL,
		strategy TEXT NOT NULL,
		booked_at TIMESTAMP DEFAULT NOW()
	);`

	_, err := pool.Exec(ctx, schema)
	if err != nil {
		fmt.Printf("Failed to create schema: %v\n", err)
		return
	}
	fmt.Println("Schema created successfully!")

	seed := `
	INSERT INTO tickets (event_name, seat_number) VALUES
		('Go Conference 2026', 'A1'),
		('Go Conference 2026', 'A2'),
		('Go Conference 2026', 'A3'),
		('Go Conference 2026', 'A4'),
		('Go Conference 2026', 'A5');`

	_, err = pool.Exec(ctx, seed)
	if err != nil {
		fmt.Printf("Failed to seed data: %v\n", err)
		return
	}
	fmt.Println("Seeded 5 tickets for Go Conference 2026!")
}

func resetTickets(ctx context.Context, pool *pgxpool.Pool) {
	pool.Exec(ctx, `UPDATE tickets SET is_booked = FALSE, booked_by = NULL, version = 1`)
	pool.Exec(ctx, `DELETE FROM bookings`)
}

Your database is ready with 5 concert seats waiting to be fought over. Next up, you will write the pessimistic locking function and watch 10 goroutines race for the same seat.

Implement Pessimistic Locking with SELECT FOR UPDATE

Your database schema is ready with tickets and bookings tables. Now comes the core challenge: what happens when 10 buyers try to grab the same last seat at the exact same moment?

Pessimistic locking solves this by making each buyer wait in line. When one buyer reads the ticket row, PostgreSQL locks that row so no other transaction can touch it until the first buyer finishes. In this step, you will implement this pattern using SELECT FOR UPDATE and launch 10 concurrent goroutines to race for the same ticket.

In this step, get ready to:
  • Write a booking function that locks a row with SELECT FOR UPDATE before modifying it.
  • Launch 10 goroutines that race to book the same ticket simultaneously.
  • Observe exactly 1 success and 9 failures with no double-booking.
Write the pessimistic booking function

The pessimistic booking function wraps a full transaction around reading and writing the ticket. The FOR UPDATE clause on the SELECT statement tells PostgreSQL to lock the row, forcing any other transaction that wants the same row to wait.

  • In main.go, add the following function below the resetTickets function:
func bookWithPessimisticLock(ctx context.Context, pool *pgxpool.Pool, ticketID int, buyer string) error {
	// Start a database transaction
	tx, err := pool.Begin(ctx)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback(ctx)

	// Lock the row with FOR UPDATE — other transactions must wait
	var isBooked bool
	err = tx.QueryRow(ctx,
		`SELECT is_booked FROM tickets WHERE id = $1 FOR UPDATE`,
		ticketID,
	).Scan(&isBooked)
	if err != nil {
		return fmt.Errorf("select for update: %w", err)
	}

	// If someone else already booked it, fail cleanly
	if isBooked {
		return fmt.Errorf("seat already booked")
	}

	// Mark the ticket as booked
	_, err = tx.Exec(ctx,
		`UPDATE tickets SET is_booked = TRUE, booked_by = $1 WHERE id = $2`,
		buyer, ticketID,
	)
	if err != nil {
		return fmt.Errorf("update ticket: %w", err)
	}

	// Record the booking
	_, err = tx.Exec(ctx,
		`INSERT INTO bookings (ticket_id, buyer_name, strategy) VALUES ($1, $2, 'pessimistic')`,
		ticketID, buyer,
	)
	if err != nil {
		return fmt.Errorf("insert booking: %w", err)
	}

	// Commit releases the lock
	return tx.Commit(ctx)
}

What does this code do?

This function follows a strict sequence to prevent double-booking:

  • pool.Begin(ctx) starts a transaction. Everything inside it either fully succeeds or fully rolls back.
  • SELECT ... FOR UPDATE reads the ticket AND places an exclusive lock on that row. Any other transaction trying to read the same row with FOR UPDATE will block and wait.
  • defer tx.Rollback(ctx) acts as a safety net. If anything fails before Commit, the transaction rolls back automatically.
  • tx.Commit(ctx) finalizes the booking and releases the lock, allowing the next waiting goroutine to proceed.

You won't see a change yet. The next substep wires this function into a concurrent demo.

Write the concurrent simulation

Now you need a function that launches 10 goroutines to race for the same ticket. You also need to update your imports and main() function to call the demo.

  • In main.go, find your import block at the top of the file:
import (
	"context"
	"fmt"

	"github.com/jackc/pgx/v5/pgxpool"
)
  • Replace the entire import block with this updated version that adds sync and sync/atomic:
import (
	"context"
	"fmt"
	"sync"
	"sync/atomic"

	"github.com/jackc/pgx/v5/pgxpool"
)

Why sync and sync/atomic?

sync.WaitGroup lets the main function wait until all 10 goroutines finish. atomic.Int32 provides thread-safe counters so multiple goroutines can increment success/fail counts without a race condition.

  • In your main() function, find this line:
	setupSchema(ctx, pool)
}
  • Replace the closing } with the following so that main() resets tickets and runs the pessimistic demo:
	setupSchema(ctx, pool)

	fmt.Println("\n========== PESSIMISTIC LOCKING (SELECT FOR UPDATE) ==========")
	resetTickets(ctx, pool)
	runPessimisticDemo(ctx, pool)
}
  • Now add the runPessimisticDemo function below resetTickets and above bookWithPessimisticLock:
func runPessimisticDemo(ctx context.Context, pool *pgxpool.Pool) (int32, int32) {
	var wg sync.WaitGroup
	var successCount atomic.Int32
	var failCount atomic.Int32

	// Launch 10 goroutines all targeting the same ticket
	for i := 1; i <= 10; i++ {
		wg.Add(1)
		go func(buyerID int) {
			defer wg.Done()
			buyer := fmt.Sprintf("Buyer-%d", buyerID)
			err := bookWithPessimisticLock(ctx, pool, 1, buyer)
			if err != nil {
				failCount.Add(1)
				fmt.Printf("  [PESSIMISTIC] %s failed: %v\n", buyer, err)
			} else {
				successCount.Add(1)
				fmt.Printf("  [PESSIMISTIC] %s successfully booked seat A1!\n", buyer)
			}
		}(i)
	}

	// Wait for all goroutines to finish
	wg.Wait()
	fmt.Printf("\nResults: %d succeeded, %d failed\n", successCount.Load(), failCount.Load())
	return successCount.Load(), failCount.Load()
}

What does this code do?

This function simulates 10 concurrent buyers racing for the same seat:

  • The for loop launches 10 goroutines. Each one calls bookWithPessimisticLock for ticket ID 1 (seat A1).
  • wg.Add(1) and wg.Done() keep track of how many goroutines are still running.
  • wg.Wait() blocks until all 10 goroutines complete, then prints the final results.
  • atomic.Int32 ensures the success and fail counters update safely even when multiple goroutines write simultaneously.
  • Save main.go.

Seeing a compile error?

Make sure your import block includes both "sync" and "sync/atomic". Go will refuse to compile if an import is missing or unused.

Also check that runPessimisticDemo(ctx, pool) in main() matches the function signature exactly. The function returns two values but you are discarding them in main, which is fine in Go.

Help me debug my compile error.

✔️ Awesome, I've got everything!

Great. Double check that you have saved main.go before running it in the next substep.

ⓧ I'd like to double check the full code

package main

import (
	"context"
	"fmt"
	"sync"
	"sync/atomic"

	"github.com/jackc/pgx/v5/pgxpool"
)

const connString = "postgres://postgres:secret@localhost:5432/concertdb?sslmode=disable"

func main() {
	ctx := context.Background()

	pool, err := pgxpool.New(ctx, connString)
	if err != nil {
		fmt.Printf("Unable to connect to database: %v\n", err)
		return
	}
	defer pool.Close()

	err = pool.Ping(ctx)
	if err != nil {
		fmt.Printf("Unable to ping database: %v\n", err)
		return
	}
	fmt.Println("Connected to PostgreSQL!")

	setupSchema(ctx, pool)

	fmt.Println("\n========== PESSIMISTIC LOCKING (SELECT FOR UPDATE) ==========")
	resetTickets(ctx, pool)
	runPessimisticDemo(ctx, pool)
}

func setupSchema(ctx context.Context, pool *pgxpool.Pool) {
	schema := `
	DROP TABLE IF EXISTS bookings;
	DROP TABLE IF EXISTS tickets;

	CREATE TABLE tickets (
		id SERIAL PRIMARY KEY,
		event_name TEXT NOT NULL,
		seat_number TEXT NOT NULL,
		is_booked BOOLEAN NOT NULL DEFAULT FALSE,
		booked_by TEXT,
		version INTEGER NOT NULL DEFAULT 1
	);

	CREATE TABLE bookings (
		id SERIAL PRIMARY KEY,
		ticket_id INTEGER REFERENCES tickets(id),
		buyer_name TEXT NOT NULL,
		strategy TEXT NOT NULL,
		booked_at TIMESTAMP DEFAULT NOW()
	);`

	_, err := pool.Exec(ctx, schema)
	if err != nil {
		fmt.Printf("Failed to create schema: %v\n", err)
		return
	}
	fmt.Println("Schema created successfully!")

	seed := `
	INSERT INTO tickets (event_name, seat_number) VALUES
		('Go Conference 2026', 'A1'),
		('Go Conference 2026', 'A2'),
		('Go Conference 2026', 'A3'),
		('Go Conference 2026', 'A4'),
		('Go Conference 2026', 'A5');`

	_, err = pool.Exec(ctx, seed)
	if err != nil {
		fmt.Printf("Failed to seed data: %v\n", err)
		return
	}
	fmt.Println("Seeded 5 tickets for Go Conference 2026!")
}

func resetTickets(ctx context.Context, pool *pgxpool.Pool) {
	pool.Exec(ctx, `UPDATE tickets SET is_booked = FALSE, booked_by = NULL, version = 1`)
	pool.Exec(ctx, `DELETE FROM bookings`)
}

func runPessimisticDemo(ctx context.Context, pool *pgxpool.Pool) (int32, int32) {
	var wg sync.WaitGroup
	var successCount atomic.Int32
	var failCount atomic.Int32

	for i := 1; i <= 10; i++ {
		wg.Add(1)
		go func(buyerID int) {
			defer wg.Done()
			buyer := fmt.Sprintf("Buyer-%d", buyerID)
			err := bookWithPessimisticLock(ctx, pool, 1, buyer)
			if err != nil {
				failCount.Add(1)
				fmt.Printf("  [PESSIMISTIC] %s failed: %v\n", buyer, err)
			} else {
				successCount.Add(1)
				fmt.Printf("  [PESSIMISTIC] %s successfully booked seat A1!\n", buyer)
			}
		}(i)
	}

	wg.Wait()
	fmt.Printf("\nResults: %d succeeded, %d failed\n", successCount.Load(), failCount.Load())
	return successCount.Load(), failCount.Load()
}

func bookWithPessimisticLock(ctx context.Context, pool *pgxpool.Pool, ticketID int, buyer string) error {
	tx, err := pool.Begin(ctx)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback(ctx)

	var isBooked bool
	err = tx.QueryRow(ctx,
		`SELECT is_booked FROM tickets WHERE id = $1 FOR UPDATE`,
		ticketID,
	).Scan(&isBooked)
	if err != nil {
		return fmt.Errorf("select for update: %w", err)
	}

	if isBooked {
		return fmt.Errorf("seat already booked")
	}

	_, err = tx.Exec(ctx,
		`UPDATE tickets SET is_booked = TRUE, booked_by = $1 WHERE id = $2`,
		buyer, ticketID,
	)
	if err != nil {
		return fmt.Errorf("update ticket: %w", err)
	}

	_, err = tx.Exec(ctx,
		`INSERT INTO bookings (ticket_id, buyer_name, strategy) VALUES ($1, $2, 'pessimistic')`,
		ticketID, buyer,
	)
	if err != nil {
		return fmt.Errorf("insert booking: %w", err)
	}

	return tx.Commit(ctx)
}
Run and observe the race

All 10 goroutines will fire at the same time, each trying to lock and book the same seat. Only one can win.

  • Run your application by executing this command:
go run main.go
  • You should see output showing exactly 1 buyer successfully booking seat A1, and 9 buyers failing with seat already booked.

What should I see?

Your output will look something like this (buyer numbers vary because goroutines run in unpredictable order):

========== PESSIMISTIC LOCKING (SELECT FOR UPDATE) ==========
  [PESSIMISTIC] Buyer-3 successfully booked seat A1!
  [PESSIMISTIC] Buyer-7 failed: seat already booked
  [PESSIMISTIC] Buyer-1 failed: seat already booked
  ...

Results: 1 succeeded, 9 failed

The specific buyer who wins changes each run. That is normal. The important thing is that exactly 1 succeeds and 9 fail. No double-booking is possible because FOR UPDATE forces each goroutine to wait its turn before reading the row.

Why does this prevent double-booking?

Without FOR UPDATE, all 10 goroutines could read is_booked = FALSE at the same instant. Each would think the seat is free and proceed to book it. That is a race condition.

With FOR UPDATE, the first goroutine to reach the SELECT locks the row. The other 9 are forced to wait. When the lock releases, the next goroutine reads the updated is_booked = TRUE value and fails cleanly.

Seeing a connection error?

Make sure your concert-db container is still running. Check with podman ps. If it is not listed, restart it with podman start concert-db.

Help me troubleshoot my database connection error.

Your pessimistic locking demo proves that row-level locks guarantee safety under concurrency. Next up, you will implement the opposite approach: optimistic locking, where no locks are held and conflicts are detected after the fact.

Implement Optimistic Locking with a Version Column

Your pessimistic locking demo proves that SELECT FOR UPDATE serializes access by making goroutines wait in line. That works, but holding a lock means every other buyer is blocked until the first transaction finishes.

Optimistic locking takes the opposite approach. Instead of blocking readers, it lets everyone read freely and detects conflicts at write time using a version column. If two buyers read the same version and one writes first, the second buyer's write fails because the version no longer matches.

In this step, get ready to:
  • Write an optimistic booking function that detects conflicts via a version column check.
  • Build a concurrent simulation with 10 goroutines racing for the same ticket.
  • Run both demos back-to-back and compare pessimistic vs optimistic output.
Write the optimistic booking function

The version column in your tickets table already exists from the schema you created in Step 2. Every ticket starts at version 1. The optimistic booking function reads this version, then uses it as a condition in the UPDATE statement.

  • In main.go, add the following function at the bottom of the file:
func bookWithOptimisticLock(ctx context.Context, pool *pgxpool.Pool, ticketID int, buyer string, retryCount *atomic.Int32) error {
	// Allow up to 3 attempts before giving up
	maxRetries := 3

	for attempt := 1; attempt <= maxRetries; attempt++ {
		// Read the ticket state WITHOUT any lock
		var isBooked bool
		var version int
		err := pool.QueryRow(ctx,
			`SELECT is_booked, version FROM tickets WHERE id = $1`,
			ticketID,
		).Scan(&isBooked, &version)
		if err != nil {
			return fmt.Errorf("read ticket: %w", err)
		}

		// If already booked, no point retrying
		if isBooked {
			return fmt.Errorf("seat already booked")
		}

		// Attempt the update with a version guard
		result, err := pool.Exec(ctx,
			`UPDATE tickets SET is_booked = TRUE, booked_by = $1, version = version + 1
			 WHERE id = $2 AND version = $3 AND is_booked = FALSE`,
			buyer, ticketID, version,
		)
		if err != nil {
			return fmt.Errorf("update ticket: %w", err)
		}

		// If 0 rows affected, someone else changed the version first
		if result.RowsAffected() == 0 {
			retryCount.Add(1)
			if attempt < maxRetries {
				fmt.Printf("  [OPTIMISTIC] %s: conflict on attempt %d, retrying...\n", buyer, attempt)
				time.Sleep(time.Duration(attempt*10) * time.Millisecond)
				continue
			}
			return fmt.Errorf("max retries exceeded, could not book")
		}

		// Booking succeeded, record it
		_, err = pool.Exec(ctx,
			`INSERT INTO bookings (ticket_id, buyer_name, strategy) VALUES ($1, $2, 'optimistic')`,
			ticketID, buyer,
		)
		if err != nil {
			return fmt.Errorf("insert booking: %w", err)
		}

		return nil
	}

	return fmt.Errorf("booking failed after %d attempts", maxRetries)
}

What does this code do?

This function takes a fundamentally different approach to the pessimistic version:

  • The SELECT has no FOR UPDATE clause. Other goroutines can read the same row simultaneously without waiting.
  • The UPDATE includes AND version = $3. If another goroutine already incremented the version, this condition fails and 0 rows are affected.
  • When result.RowsAffected() returns 0, the function knows a conflict occurred. It waits briefly with backoff, then tries again from the beginning.
  • After 3 failed attempts, it gives up and returns an error.

Why no transaction here?

The pessimistic function used a transaction to hold the lock across multiple statements. The optimistic function does not need a transaction for the read-then-write pattern because the version check in the UPDATE itself acts as the concurrency guard. If the version changed between your read and your write, the UPDATE simply affects 0 rows.

You will not see a change yet. The next chunk adds the demo runner that calls this function.

Write the concurrent simulation

This simulation is structurally identical to the pessimistic demo. The difference is that it also tracks a retryCount so you can see how many version conflicts occurred across all goroutines.

  • In main.go, add the following function below bookWithOptimisticLock:
func runOptimisticDemo(ctx context.Context, pool *pgxpool.Pool) (int32, int32, int32) {
	var wg sync.WaitGroup
	var successCount atomic.Int32
	var failCount atomic.Int32
	var retryCount atomic.Int32

	// Launch 10 goroutines all racing for the same ticket
	for i := 1; i <= 10; i++ {
		wg.Add(1)
		go func(buyerID int) {
			defer wg.Done()
			buyer := fmt.Sprintf("Buyer-%d", buyerID)
			err := bookWithOptimisticLock(ctx, pool, 1, buyer, &retryCount)
			if err != nil {
				failCount.Add(1)
				fmt.Printf("  [OPTIMISTIC] %s failed: %v\n", buyer, err)
			} else {
				successCount.Add(1)
				fmt.Printf("  [OPTIMISTIC] %s successfully booked seat A1!\n", buyer)
			}
		}(i)
	}

	wg.Wait()
	fmt.Printf("\nResults: %d succeeded, %d failed, %d retries\n",
		successCount.Load(), failCount.Load(), retryCount.Load())
	return successCount.Load(), failCount.Load(), retryCount.Load()
}

What does this code do?

  • 10 goroutines all call bookWithOptimisticLock for ticket ID 1 (seat A1) at the same time.
  • Each goroutine passes a shared &retryCount pointer so the total number of version conflicts is tracked across all buyers.
  • The function returns three values (successes, failures, retries) so the comparison summary in the next step can use them.

You will not see a change yet. The next chunk wires this into your main function so both demos run.

Run and compare both strategies

Now you need to call your new optimistic demo from main() and add the time import that bookWithOptimisticLock depends on for its retry backoff.

  • In main.go, find your import block at the top of the file:
import (
	"context"
	"fmt"
	"sync"
	"sync/atomic"

	"github.com/jackc/pgx/v5/pgxpool"
)
  • Add "time" after "sync/atomic" so the import block looks like this:
import (
	"context"
	"fmt"
	"sync"
	"sync/atomic"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
)
  • Next, find the end of your main() function. Find this line:
	runPessimisticDemo(ctx, pool)
  • Add the following code directly below that line to run the optimistic demo after the pessimistic one:
	fmt.Println("\n========== OPTIMISTIC LOCKING (Version Column) ==========")
	resetTickets(ctx, pool)
	runOptimisticDemo(ctx, pool)

Why reset tickets between demos?

The resetTickets call sets all tickets back to unbooked and resets versions to 1. Without this, the optimistic demo would immediately see seat A1 as booked from the pessimistic demo and every goroutine would fail on the first read.

  • Save main.go.
  • Run the application by running:
go run main.go
  • You should see both demos run back-to-back. The pessimistic demo shows 1 success and 9 failures with no retries. The optimistic demo also shows 1 success and 9 failures, but includes "conflict on attempt X, retrying..." messages from goroutines that detected version mismatches.

How does the output differ?

In the pessimistic output, every failure is a clean "seat already booked" message. The goroutine waited for the lock, checked the state, and found it taken.

In the optimistic output, some goroutines print "conflict on attempt X, retrying..." before eventually failing. They read the seat as available, attempted the write, but the version had changed. After retrying and finding the seat booked, they gave up.

Seeing a compile error about time?

Make sure you added "time" to your import block. Go will not compile if a used package is missing from imports.

If you see "declared and not used" errors instead, check that your main() function calls runOptimisticDemo(ctx, pool).

Help me debug my optimistic locking compile error.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete main.go at this point:

package main

import (
	"context"
	"fmt"
	"sync"
	"sync/atomic"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
)

const connString = "postgres://postgres:secret@localhost:5432/concertdb?sslmode=disable"

func main() {
	ctx := context.Background()

	pool, err := pgxpool.New(ctx, connString)
	if err != nil {
		fmt.Printf("Unable to connect to database: %v\n", err)
		return
	}
	defer pool.Close()

	err = pool.Ping(ctx)
	if err != nil {
		fmt.Printf("Unable to ping database: %v\n", err)
		return
	}
	fmt.Println("Connected to PostgreSQL!")

	setupSchema(ctx, pool)

	fmt.Println("\n========== PESSIMISTIC LOCKING (SELECT FOR UPDATE) ==========")
	resetTickets(ctx, pool)
	runPessimisticDemo(ctx, pool)

	fmt.Println("\n========== OPTIMISTIC LOCKING (Version Column) ==========")
	resetTickets(ctx, pool)
	runOptimisticDemo(ctx, pool)
}

func setupSchema(ctx context.Context, pool *pgxpool.Pool) {
	schema := `
	DROP TABLE IF EXISTS bookings;
	DROP TABLE IF EXISTS tickets;

	CREATE TABLE tickets (
		id SERIAL PRIMARY KEY,
		event_name TEXT NOT NULL,
		seat_number TEXT NOT NULL,
		is_booked BOOLEAN NOT NULL DEFAULT FALSE,
		booked_by TEXT,
		version INTEGER NOT NULL DEFAULT 1
	);

	CREATE TABLE bookings (
		id SERIAL PRIMARY KEY,
		ticket_id INTEGER REFERENCES tickets(id),
		buyer_name TEXT NOT NULL,
		strategy TEXT NOT NULL,
		booked_at TIMESTAMP DEFAULT NOW()
	);`

	_, err := pool.Exec(ctx, schema)
	if err != nil {
		fmt.Printf("Failed to create schema: %v\n", err)
		return
	}
	fmt.Println("Schema created successfully!")

	seed := `
	INSERT INTO tickets (event_name, seat_number) VALUES
		('Go Conference 2026', 'A1'),
		('Go Conference 2026', 'A2'),
		('Go Conference 2026', 'A3'),
		('Go Conference 2026', 'A4'),
		('Go Conference 2026', 'A5');`

	_, err = pool.Exec(ctx, seed)
	if err != nil {
		fmt.Printf("Failed to seed data: %v\n", err)
		return
	}
	fmt.Println("Seeded 5 tickets for Go Conference 2026!")
}

func resetTickets(ctx context.Context, pool *pgxpool.Pool) {
	pool.Exec(ctx, `UPDATE tickets SET is_booked = FALSE, booked_by = NULL, version = 1`)
	pool.Exec(ctx, `DELETE FROM bookings`)
}

func runPessimisticDemo(ctx context.Context, pool *pgxpool.Pool) (int32, int32) {
	var wg sync.WaitGroup
	var successCount atomic.Int32
	var failCount atomic.Int32

	for i := 1; i <= 10; i++ {
		wg.Add(1)
		go func(buyerID int) {
			defer wg.Done()
			buyer := fmt.Sprintf("Buyer-%d", buyerID)
			err := bookWithPessimisticLock(ctx, pool, 1, buyer)
			if err != nil {
				failCount.Add(1)
				fmt.Printf("  [PESSIMISTIC] %s failed: %v\n", buyer, err)
			} else {
				successCount.Add(1)
				fmt.Printf("  [PESSIMISTIC] %s successfully booked seat A1!\n", buyer)
			}
		}(i)
	}

	wg.Wait()
	fmt.Printf("\nResults: %d succeeded, %d failed\n", successCount.Load(), failCount.Load())
	return successCount.Load(), failCount.Load()
}

func bookWithPessimisticLock(ctx context.Context, pool *pgxpool.Pool, ticketID int, buyer string) error {
	tx, err := pool.Begin(ctx)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback(ctx)

	var isBooked bool
	err = tx.QueryRow(ctx,
		`SELECT is_booked FROM tickets WHERE id = $1 FOR UPDATE`,
		ticketID,
	).Scan(&isBooked)
	if err != nil {
		return fmt.Errorf("select for update: %w", err)
	}

	if isBooked {
		return fmt.Errorf("seat already booked")
	}

	_, err = tx.Exec(ctx,
		`UPDATE tickets SET is_booked = TRUE, booked_by = $1 WHERE id = $2`,
		buyer, ticketID,
	)
	if err != nil {
		return fmt.Errorf("update ticket: %w", err)
	}

	_, err = tx.Exec(ctx,
		`INSERT INTO bookings (ticket_id, buyer_name, strategy) VALUES ($1, $2, 'pessimistic')`,
		ticketID, buyer,
	)
	if err != nil {
		return fmt.Errorf("insert booking: %w", err)
	}

	return tx.Commit(ctx)
}

func runOptimisticDemo(ctx context.Context, pool *pgxpool.Pool) (int32, int32, int32) {
	var wg sync.WaitGroup
	var successCount atomic.Int32
	var failCount atomic.Int32
	var retryCount atomic.Int32

	for i := 1; i <= 10; i++ {
		wg.Add(1)
		go func(buyerID int) {
			defer wg.Done()
			buyer := fmt.Sprintf("Buyer-%d", buyerID)
			err := bookWithOptimisticLock(ctx, pool, 1, buyer, &retryCount)
			if err != nil {
				failCount.Add(1)
				fmt.Printf("  [OPTIMISTIC] %s failed: %v\n", buyer, err)
			} else {
				successCount.Add(1)
				fmt.Printf("  [OPTIMISTIC] %s successfully booked seat A1!\n", buyer)
			}
		}(i)
	}

	wg.Wait()
	fmt.Printf("\nResults: %d succeeded, %d failed, %d retries\n",
		successCount.Load(), failCount.Load(), retryCount.Load())
	return successCount.Load(), failCount.Load(), retryCount.Load()
}

func bookWithOptimisticLock(ctx context.Context, pool *pgxpool.Pool, ticketID int, buyer string, retryCount *atomic.Int32) error {
	maxRetries := 3

	for attempt := 1; attempt <= maxRetries; attempt++ {
		var isBooked bool
		var version int
		err := pool.QueryRow(ctx,
			`SELECT is_booked, version FROM tickets WHERE id = $1`,
			ticketID,
		).Scan(&isBooked, &version)
		if err != nil {
			return fmt.Errorf("read ticket: %w", err)
		}

		if isBooked {
			return fmt.Errorf("seat already booked")
		}

		result, err := pool.Exec(ctx,
			`UPDATE tickets SET is_booked = TRUE, booked_by = $1, version = version + 1
			 WHERE id = $2 AND version = $3 AND is_booked = FALSE`,
			buyer, ticketID, version,
		)
		if err != nil {
			return fmt.Errorf("update ticket: %w", err)
		}

		if result.RowsAffected() == 0 {
			retryCount.Add(1)
			if attempt < maxRetries {
				fmt.Printf("  [OPTIMISTIC] %s: conflict on attempt %d, retrying...\n", buyer, attempt)
				time.Sleep(time.Duration(attempt*10) * time.Millisecond)
				continue
			}
			return fmt.Errorf("max retries exceeded, could not book")
		}

		_, err = pool.Exec(ctx,
			`INSERT INTO bookings (ticket_id, buyer_name, strategy) VALUES ($1, $2, 'optimistic')`,
			ticketID, buyer,
		)
		if err != nil {
			return fmt.Errorf("insert booking: %w", err)
		}

		return nil
	}

	return fmt.Errorf("booking failed after %d attempts", maxRetries)
}

You now have two working concurrency strategies side by side. Next up, you will add timing measurements to quantify the performance difference between blocking (pessimistic) and retrying (optimistic).

Analyze and Compare Both Strategies

You have two working concurrency strategies that both prevent double-booking. The natural next question is: which one should you actually use in production?

The answer depends on contention. In this step, you will add timing measurements and a comparison table so you can see the performance difference with real data.

In this step, get ready to:
  • Wrap each demo in timing code to measure elapsed duration.
  • Print a formatted comparison summary table.
  • Run the application and interpret the performance differences.
Add timing measurements

Go's time package (already imported) lets you capture a start timestamp and compute the elapsed duration. You will wrap each demo call in timing code and capture the return values for a summary later.

  • In main.go, find these lines inside main() (after setupSchema(ctx, pool)):
	fmt.Println("\n========== PESSIMISTIC LOCKING (SELECT FOR UPDATE) ==========")
	resetTickets(ctx, pool)
	runPessimisticDemo(ctx, pool)

	fmt.Println("\n========== OPTIMISTIC LOCKING (Version Column) ==========")
	resetTickets(ctx, pool)
	runOptimisticDemo(ctx, pool)
  • Replace those lines with the following timing-wrapped version:
	// Pessimistic locking demo
	fmt.Println("\n========== PESSIMISTIC LOCKING (SELECT FOR UPDATE) ==========")
	resetTickets(ctx, pool)
	pessStart := time.Now()
	pessSuccesses, pessFailures := runPessimisticDemo(ctx, pool)
	pessDuration := time.Since(pessStart)

	// Optimistic locking demo
	fmt.Println("\n========== OPTIMISTIC LOCKING (Version Column) ==========")
	resetTickets(ctx, pool)
	optStart := time.Now()
	optSuccesses, optFailures, optRetries := runOptimisticDemo(ctx, pool)
	optDuration := time.Since(optStart)

What does this code do?

  • time.Now() captures the exact moment before the demo starts.
  • time.Since(pessStart) computes how much time elapsed between the start and now.
  • The return values (pessSuccesses, pessFailures, optRetries) are captured so you can print them in a summary table.

You will not see a change yet. The next chunk uses these captured variables in a formatted table.

Print a comparison summary

A side-by-side table makes the strategy differences immediately visible. You will add formatted output that prints after both demos finish.

  • Still in main.go, add the following code directly below the optDuration := time.Since(optStart) line (before the closing } of main()):
	// Summary
	fmt.Println("\n========== COMPARISON SUMMARY ==========")
	fmt.Printf("%-12s | %-9s | %-8s | %-7s | %s\n", "Strategy", "Successes", "Failures", "Retries", "Duration")
	fmt.Println("-------------|-----------|----------|---------|----------")
	fmt.Printf("%-12s | %-9d | %-8d | %-7d | %v\n", "Pessimistic", pessSuccesses, pessFailures, 0, pessDuration)
	fmt.Printf("%-12s | %-9d | %-8d | %-7d | %v\n", "Optimistic", optSuccesses, optFailures, optRetries, optDuration)

What does this code do?

  • %-12s is Go's format verb for a left-aligned string padded to 12 characters. This keeps table columns aligned.
  • The pessimistic row hardcodes 0 for retries because that strategy never retries. It blocks and waits instead.
  • The optimistic row uses the real optRetries count showing how many conflict-and-retry cycles occurred.
  • Save main.go.

Seeing a compile error about unused variables?

Go requires every declared variable to be used. If you added the timing variables (pessSuccesses, pessDuration, etc.) but did not add the summary Printf block that references them, the compiler will reject the file. Make sure both chunks are in place before running.

help me fix an unused variable compile error in my Go concurrency demo

Run and compare both strategies

With timing and summary in place, you can now see both strategies race side-by-side and compare their real-world behavior.

  • Run the application by executing this command:
go run main.go
  • You should see both demos run with their booking output, followed by a comparison table at the bottom showing Strategy, Successes, Failures, Retries, and Duration columns.

What should I see?

The pessimistic demo shows clean pass/fail messages with no retries. Each goroutine waits its turn behind the FOR UPDATE lock, checks the state, and either books or fails.

The optimistic demo shows retry messages as goroutines detect version conflicts. No blocking happens during the read phase, but some writes fail and must be retried.

Under low contention (like this scenario), optimistic locking is typically faster because goroutines do not block each other during the read phase. Under high contention, pessimistic locking can be more predictable because it avoids retry storms.

Not seeing the comparison table?

Make sure the summary Printf lines are placed inside main() after the optimistic demo section, not outside the function. Also confirm you used \n (escaped newline) in the fmt.Println string for the header.

help me debug my comparison summary table not printing

✔️ Awesome, I've got everything!

Great. Double check you have saved main.go and the summary table prints with real timing data.

ⓧ I'd like to double check the full code

package main

import (
	"context"
	"fmt"
	"sync"
	"sync/atomic"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
)

const connString = "postgres://postgres:secret@localhost:5432/concertdb?sslmode=disable"

func main() {
	ctx := context.Background()

	pool, err := pgxpool.New(ctx, connString)
	if err != nil {
		fmt.Printf("Unable to connect to database: %v\n", err)
		return
	}
	defer pool.Close()

	err = pool.Ping(ctx)
	if err != nil {
		fmt.Printf("Unable to ping database: %v\n", err)
		return
	}
	fmt.Println("Connected to PostgreSQL!")

	setupSchema(ctx, pool)

	// Pessimistic locking demo
	fmt.Println("\n========== PESSIMISTIC LOCKING (SELECT FOR UPDATE) ==========")
	resetTickets(ctx, pool)
	pessStart := time.Now()
	pessSuccesses, pessFailures := runPessimisticDemo(ctx, pool)
	pessDuration := time.Since(pessStart)

	// Optimistic locking demo
	fmt.Println("\n========== OPTIMISTIC LOCKING (Version Column) ==========")
	resetTickets(ctx, pool)
	optStart := time.Now()
	optSuccesses, optFailures, optRetries := runOptimisticDemo(ctx, pool)
	optDuration := time.Since(optStart)

	// Summary
	fmt.Println("\n========== COMPARISON SUMMARY ==========")
	fmt.Printf("%-12s | %-9s | %-8s | %-7s | %s\n", "Strategy", "Successes", "Failures", "Retries", "Duration")
	fmt.Println("-------------|-----------|----------|---------|----------")
	fmt.Printf("%-12s | %-9d | %-8d | %-7d | %v\n", "Pessimistic", pessSuccesses, pessFailures, 0, pessDuration)
	fmt.Printf("%-12s | %-9d | %-8d | %-7d | %v\n", "Optimistic", optSuccesses, optFailures, optRetries, optDuration)
}

func setupSchema(ctx context.Context, pool *pgxpool.Pool) {
	schema := `
	DROP TABLE IF EXISTS bookings;
	DROP TABLE IF EXISTS tickets;

	CREATE TABLE tickets (
		id SERIAL PRIMARY KEY,
		event_name TEXT NOT NULL,
		seat_number TEXT NOT NULL,
		is_booked BOOLEAN NOT NULL DEFAULT FALSE,
		booked_by TEXT,
		version INTEGER NOT NULL DEFAULT 1
	);

	CREATE TABLE bookings (
		id SERIAL PRIMARY KEY,
		ticket_id INTEGER REFERENCES tickets(id),
		buyer_name TEXT NOT NULL,
		strategy TEXT NOT NULL,
		booked_at TIMESTAMP DEFAULT NOW()
	);`

	_, err := pool.Exec(ctx, schema)
	if err != nil {
		fmt.Printf("Failed to create schema: %v\n", err)
		return
	}
	fmt.Println("Schema created successfully!")

	seed := `
	INSERT INTO tickets (event_name, seat_number) VALUES
		('Go Conference 2026', 'A1'),
		('Go Conference 2026', 'A2'),
		('Go Conference 2026', 'A3'),
		('Go Conference 2026', 'A4'),
		('Go Conference 2026', 'A5');`

	_, err = pool.Exec(ctx, seed)
	if err != nil {
		fmt.Printf("Failed to seed data: %v\n", err)
		return
	}
	fmt.Println("Seeded 5 tickets for Go Conference 2026!")
}

func resetTickets(ctx context.Context, pool *pgxpool.Pool) {
	pool.Exec(ctx, `UPDATE tickets SET is_booked = FALSE, booked_by = NULL, version = 1`)
	pool.Exec(ctx, `DELETE FROM bookings`)
}

func runPessimisticDemo(ctx context.Context, pool *pgxpool.Pool) (int32, int32) {
	var wg sync.WaitGroup
	var successCount atomic.Int32
	var failCount atomic.Int32

	for i := 1; i <= 10; i++ {
		wg.Add(1)
		go func(buyerID int) {
			defer wg.Done()
			buyer := fmt.Sprintf("Buyer-%d", buyerID)
			err := bookWithPessimisticLock(ctx, pool, 1, buyer)
			if err != nil {
				failCount.Add(1)
				fmt.Printf("  [PESSIMISTIC] %s failed: %v\n", buyer, err)
			} else {
				successCount.Add(1)
				fmt.Printf("  [PESSIMISTIC] %s successfully booked seat A1!\n", buyer)
			}
		}(i)
	}

	wg.Wait()
	fmt.Printf("\nResults: %d succeeded, %d failed\n", successCount.Load(), failCount.Load())
	return successCount.Load(), failCount.Load()
}

func bookWithPessimisticLock(ctx context.Context, pool *pgxpool.Pool, ticketID int, buyer string) error {
	tx, err := pool.Begin(ctx)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback(ctx)

	var isBooked bool
	err = tx.QueryRow(ctx,
		`SELECT is_booked FROM tickets WHERE id = $1 FOR UPDATE`,
		ticketID,
	).Scan(&isBooked)
	if err != nil {
		return fmt.Errorf("select for update: %w", err)
	}

	if isBooked {
		return fmt.Errorf("seat already booked")
	}

	_, err = tx.Exec(ctx,
		`UPDATE tickets SET is_booked = TRUE, booked_by = $1 WHERE id = $2`,
		buyer, ticketID,
	)
	if err != nil {
		return fmt.Errorf("update ticket: %w", err)
	}

	_, err = tx.Exec(ctx,
		`INSERT INTO bookings (ticket_id, buyer_name, strategy) VALUES ($1, $2, 'pessimistic')`,
		ticketID, buyer,
	)
	if err != nil {
		return fmt.Errorf("insert booking: %w", err)
	}

	return tx.Commit(ctx)
}

func runOptimisticDemo(ctx context.Context, pool *pgxpool.Pool) (int32, int32, int32) {
	var wg sync.WaitGroup
	var successCount atomic.Int32
	var failCount atomic.Int32
	var retryCount atomic.Int32

	for i := 1; i <= 10; i++ {
		wg.Add(1)
		go func(buyerID int) {
			defer wg.Done()
			buyer := fmt.Sprintf("Buyer-%d", buyerID)
			err := bookWithOptimisticLock(ctx, pool, 1, buyer, &retryCount)
			if err != nil {
				failCount.Add(1)
				fmt.Printf("  [OPTIMISTIC] %s failed: %v\n", buyer, err)
			} else {
				successCount.Add(1)
				fmt.Printf("  [OPTIMISTIC] %s successfully booked seat A1!\n", buyer)
			}
		}(i)
	}

	wg.Wait()
	fmt.Printf("\nResults: %d succeeded, %d failed, %d retries\n",
		successCount.Load(), failCount.Load(), retryCount.Load())
	return successCount.Load(), failCount.Load(), retryCount.Load()
}

func bookWithOptimisticLock(ctx context.Context, pool *pgxpool.Pool, ticketID int, buyer string, retryCount *atomic.Int32) error {
	maxRetries := 3

	for attempt := 1; attempt <= maxRetries; attempt++ {
		var isBooked bool
		var version int
		err := pool.QueryRow(ctx,
			`SELECT is_booked, version FROM tickets WHERE id = $1`,
			ticketID,
		).Scan(&isBooked, &version)
		if err != nil {
			return fmt.Errorf("read ticket: %w", err)
		}

		if isBooked {
			return fmt.Errorf("seat already booked")
		}

		result, err := pool.Exec(ctx,
			`UPDATE tickets SET is_booked = TRUE, booked_by = $1, version = version + 1
			 WHERE id = $2 AND version = $3 AND is_booked = FALSE`,
			buyer, ticketID, version,
		)
		if err != nil {
			return fmt.Errorf("update ticket: %w", err)
		}

		if result.RowsAffected() == 0 {
			retryCount.Add(1)
			if attempt < maxRetries {
				fmt.Printf("  [OPTIMISTIC] %s: conflict on attempt %d, retrying...\n", buyer, attempt)
				time.Sleep(time.Duration(attempt*10) * time.Millisecond)
				continue
			}
			return fmt.Errorf("max retries exceeded, could not book")
		}

		_, err = pool.Exec(ctx,
			`INSERT INTO bookings (ticket_id, buyer_name, strategy) VALUES ($1, $2, 'optimistic')`,
			ticketID, buyer,
		)
		if err != nil {
			return fmt.Errorf("insert booking: %w", err)
		}

		return nil
	}

	return fmt.Errorf("booking failed after %d attempts", maxRetries)
}

You now have measurable proof of how each strategy behaves under contention. Next up, you will tackle the Secret Mission and implement a third strategy that combines the safety of locking with the throughput of not waiting.

Secret mission

Implement FOR UPDATE SKIP LOCKED for Parallel Booking

Your pessimistic locking makes buyers wait in line. Your optimistic locking makes them retry on conflict. But what if multiple buyers could grab different seats at the same time, without waiting or retrying? Add a third strategy that production job queues and booking systems use to process work in parallel.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • One Podman container named concert-db running PostgreSQL 17 on port 5432.
  • Local project folder concurrency-demo on your Desktop containing main.go, go.mod, and go.sum.

Keep everything running

No action needed. Choose this if you are still actively experimenting with concurrency patterns or want to reuse the PostgreSQL container for other database projects.

  • The concert-db container uses minimal CPU and memory while idle.
  • Your concurrency-demo folder stays on your Desktop for future reference.

Pause - I'll come back to this later

Stop the running container to free up memory. Your data and configuration are preserved so you can restart later.

  • Stop the PostgreSQL container by running:
podman stop concert-db
  • When you are ready to resume, start it again with:
podman start concert-db

Delete - I don't want to use this again

Remove all project resources and start fresh. This cannot be undone.

  • Remove the Podman container (this stops it if still running and deletes it) by running:
podman rm -f concert-db
  • Delete the project folder from your Desktop by running:
rm -rf ~/Desktop/concurrency-demo

On Windows, use this command instead:

rmdir /s /q %USERPROFILE%\Desktop\concurrency-demo

Nice Work!

Nice Work!

You just built a concert ticket booking simulator that demonstrates real-world database concurrency control in action. Your Go application proves, with live race conditions, exactly how pessimistic and optimistic locking behave under pressure.

You've learned how to:

  • Implement pessimistic concurrency control using SELECT FOR UPDATE to serialize access to a shared row, preventing double-bookings by forcing transactions to wait.
  • Implement optimistic concurrency control using a version column and retry logic to detect conflicts at write time without holding any locks during reads.
  • Simulate and measure race conditions with 10 concurrent goroutines competing for the same resource, giving you the intuition to choose the right strategy based on contention level.
  • Secret Mission: Implemented FOR UPDATE SKIP LOCKED for parallel, non-blocking seat assignment where multiple buyers grab different available seats simultaneously.

Ready to quiz yourself?