The Borrow-a-Book bug: Why Your Go API Breaks Under Concurrent Requests

Introduction

Some bugs never show up when you test your API by hand. You click through the endpoints and everything works. Then two requests arrive at the same moment, and your data is wrong.

I ran into this while building the example application for my book Building Production Web APIs with Go: a small library API where users can borrow and return books. Borrowing a book sounds like the simplest feature imaginable, and it still contains a classic concurrency bug.

The Naive Version

The obvious implementation of POST /books/borrow has three steps:

  1. Load the book.
  2. Check that nobody has borrowed it.
  3. Mark it as borrowed by this user.
func (h *BookHandler) BorrowBook(w http.ResponseWriter, r *http.Request) {
	var req BorrowBookRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		log.Printf("Unable to decode request body: %v", err)
		writeError(w, http.StatusBadRequest, "Invalid request body")
		return
	}
	if err := req.Validate(); err != nil {
		writeError(w, http.StatusBadRequest, err.Error())
		return
	}
	if req.Days == 0 {
		req.Days = 14
	}

	// Check if book exists
	book, err := h.queries.GetBook(r.Context(), req.BookID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			writeError(w, http.StatusNotFound, "Book not found")
			return
		}
		log.Printf("Unable to get book: %v", err)
		writeError(w, http.StatusInternalServerError, "Internal server error")
		return
	}

	if !book.Available {
		// Book is not available for borrowing
		writeError(w, http.StatusConflict, "Book is not available for borrowing")
		return
	}

	_, err = h.queries.GetUser(r.Context(), req.UserID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			writeError(w, http.StatusNotFound, "User not found")
			return
		}
		log.Printf("Unable to get user: %v", err)
		writeError(w, http.StatusInternalServerError, "Internal server error")
		return
	}

	dueDate := time.Now().Add(time.Duration(req.Days) * 24 * time.Hour)

	borrowRecord, err := h.queries.BorrowBook(r.Context(), db.BorrowBookParams{
		BookID:  req.BookID,
		UserID:  req.UserID,
		DueDate: pgtype.Timestamptz{Time: dueDate, Valid: true},
	})

	if err != nil {
		log.Printf("Unable to borrow book: %v", err)
		writeError(w, http.StatusInternalServerError, "Internal server error")
		return
	}
	err = h.queries.UpdateBookAvailability(r.Context(), db.UpdateBookAvailabilityParams{
		ID:        req.BookID,
		Available: false,
	})
	if err != nil {
		log.Printf("Unable to update book availability: %v", err)
		writeError(w, http.StatusInternalServerError, "Internal server error")
		return
	}
	writeJSON(w, http.StatusOK, models.ToBorrowRecordResponse(borrowRecord))
}

It reads well, it passes manual testing, and it’s wrong.

Why It Breaks

The problem is the gap between checking and writing. Imagine Alice and Bob both click “borrow” on the last copy at the same instant:

Both requests passed the check, because neither had written anything yet. Both got a success response, and the database now holds two active loans for the same book.

This is a check-then-act race. Go itself isn’t the problem: every request handler runs concurrently, and the database is the shared state they fight over. A mutex in your Go code wouldn’t fix it either, because it only protects one process, and the moment you run two replicas (say, on Kubernetes) the problem comes back.

The Fix: A Transaction And a Row Lock

The check and the write need to happen as one atomic unit that other requests can’t interleave with. PostgreSQL gives you the tools:

-- name: GetBookForUpdate :one
SELECT * FROM books
WHERE id = $1 AND deleted_at IS NULL
FOR UPDATE;

FOR UPDATE locks the selected row until the transaction ends. If Bob’s request runs the same query while Alice’s transaction is open, his request waits. When Alice commits, Bob’s query proceeds and sees the updated row, which is now borrowed.

Wrapped in a transaction:

func (h *BookHandler) BorrowBook(w http.ResponseWriter, r *http.Request) {
	var req BorrowBookRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		log.Printf("Unable to decode request body: %v", err)
		writeError(w, http.StatusBadRequest, "Invalid request body")
		return
	}

	if err := req.Validate(); err != nil {
		writeError(w, http.StatusBadRequest, err.Error())
		return
	}

	if req.Days == 0 {
		req.Days = 14
	}
	tx, err := h.pool.BeginTx(r.Context(), pgx.TxOptions{})
	if err != nil {
		log.Printf("Unable to start transaction: %v", err)
		writeError(w, http.StatusInternalServerError, "Internal server error")
		return
	}
	defer tx.Rollback(r.Context())

	qtx := h.queries.WithTx(tx)

	//Check if book exists
	book, err := qtx.GetBookForUpdate(r.Context(), req.BookID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			writeError(w, http.StatusNotFound, "Book not found")
			return
		}
		log.Printf("Unable to get book: %v", err)
		writeError(w, http.StatusInternalServerError, "Internal server error")
		return
	}

	if !book.Available {
		// Book is not available for borrowing
		writeError(w, http.StatusConflict, "Book is not available for borrowing")
		return
	}

	_, err = qtx.GetUser(r.Context(), req.UserID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			writeError(w, http.StatusNotFound, "User not found")
			return
		}
		log.Printf("Unable to get user: %v", err)
		writeError(w, http.StatusInternalServerError, "Internal server error")
		return
	}

	dueDate := time.Now().Add(time.Duration(req.Days) * 24 * time.Hour)

	borrowRecord, err := qtx.BorrowBook(r.Context(), db.BorrowBookParams{
		BookID:  req.BookID,
		UserID:  req.UserID,
		DueDate: pgtype.Timestamptz{Time: dueDate, Valid: true},
	})

	if err != nil {
		var pgErr *pgconn.PgError

		if errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == "idx_one_active_borrow_per_book" {
			writeError(w, http.StatusConflict, "Book is not available for borrowing")
			return
		}
		log.Printf("Unable to borrow book: %v", err)
		writeError(w, http.StatusInternalServerError, "Internal server error")
		return
	}
	err = qtx.UpdateBookAvailability(r.Context(), db.UpdateBookAvailabilityParams{
		ID:        req.BookID,
		Available: false,
	})
	if err != nil {
		log.Printf("Unable to update book availability: %v", err)
		writeError(w, http.StatusInternalServerError, "Internal server error")
		return
	}

	if err = tx.Commit(r.Context()); err != nil {
		log.Printf("Unable to commit transaction: %v", err)
		writeError(w, http.StatusInternalServerError, "Internal server error")
		return
	}
	writeJSON(w, http.StatusOK, models.ToBorrowRecordResponse(borrowRecord))
}

Now the timeline looks different: Alice’s request locks the row, checks, writes, and commits. Bob’s request waits for the lock, then sees the row as it is after Alice’s commit: the book is no longer available, and the handler returns 409 Conflict.

One thing worth noting: defer tx.Rollback(ctx) is the idiomatic way to guarantee cleanup on every early return.

There’s also a lighter alternative for simple cases: a single conditional update, UPDATE books SET available = false WHERE id = $1 AND available = true, then check the number of affected rows. It works well when you don’t need to read anything first. Once the operation involves several steps, such as recording history, an explicit transaction with a lock is easier to reason about.

Add A Safety Net: Let the Database Enforce the Rule

Application code can have bugs, and a future endpoint might forget the lock. So I also let PostgreSQL enforce the invariant. If you track loans in their own table, a partial unique index guarantees that a book has at most one active loan:

CREATE UNIQUE INDEX idx_one_active_borrow_per_book
    ON borrowed_books(book_id)
    WHERE returned_at IS NULL;

Even if two requests slip past your Go logic, the second insert fails with a unique violation. The lock makes the normal path correct, and the constraint makes the wrong path impossible.

Why Your Unit Tests Won’t Catch This

This bug is easy to miss in a test suite. Unit tests for handlers usually replace the database with a test double, a fake that implements the same interface in memory. That’s great for fast, focused tests of your handler logic, but a fake database has no row locks and no concurrent transactions, so it can never reproduce this race. A test with a real PostgreSQL instance and two goroutines borrowing the same book is the only way to prove the fix works.

This is one of the lessons I wanted the book to make explicit: know what each kind of test can and can’t prove.

Takeaways

  • A check followed by a write is a race condition whenever the data is shared.
  • Fix it in the database, not in Go: a transaction plus SELECT ... FOR UPDATE, or a conditional UPDATE.
  • Back up your application logic with constraints so the database rejects invalid states.
  • Test concurrency against a real database.

This example is from Building Production Web APIs with Go, where you build a library API step by step, from a tiny HTTP server to a PostgreSQL-backed service with sqlc, tests, Docker, OpenAPI, and Kubernetes. You can read a free chapter and get the book at leanpub.com/buildingproductionwebapiswithgo.

The Code Nomad
The Code Nomad
Articles: 170

Leave a Reply

Your email address will not be published. Required fields are marked *