Dev Skills

Step 6 — Checkout, and what a transaction is for

Five things must all happen, or none of them. This is what transactions exist for.

Topic 80 of 98 · 10 min to read · 50 min to build
After this topic you can
  • Write a checkout transaction with a deferred rollback.
  • Lock product rows with SELECT ... FOR UPDATE to prevent overselling.
  • Explain why all five checkout steps must succeed or fail together.

What a transaction is

A transaction groups several database statements so they either all take effect or none of them do. You open it, run your statements, and then either commit (make it all permanent) or roll back (undo all of it).

Checkout is the textbook case. It has to create an order, copy each cart line onto it, reduce stock for each product, and empty the cart. If the server dies after reducing stock but before creating the order, a customer has been charged nothing and the shop has lost inventory. Without a transaction that can happen.

The three problems checkout has to solve

ProblemWhat goes wrong without itThe fix
Partial failurestock drops but no order existsone transaction around everything
Two people, one last itemboth succeed, stock goes to -1stock >= quantity in the UPDATE, then check RowsAffected
Prices change mid-checkoutthe order total does not match what was chargedcopy the price onto the order line

The transaction, with the rollback that always runs

internal/adapter/storage/postgres/order.go — create this file
// The named return value err is what lets the deferred function know whether
// to roll back. This is the one place naming a return really earns its keep.
func (r *Repository) Checkout(ctx context.Context, userID int64) (o *Order, err error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p) // roll back first, then let the panic continue
}
if err != nil {
tx.Rollback() // any error above undoes everything
}
}()
// ... the steps below all use tx, never r.db ...
return o, tx.Commit()
}
Every statement inside must use tx. One stray r.db call runs on another connection, outside the transaction, and will not be rolled back.

Inside, part 1 — read the cart and lock the products

FOR UPDATE locks those product rows until the transaction ends, so a second checkout waits rather than racing you. ORDER BY matters: two transactions locking the same rows in different orders can deadlock.

internal/adapter/storage/postgres/order.go — inside Checkout
rows, err := tx.QueryContext(ctx, `
SELECT ci.product_id, ci.quantity, p.name, p.price_cents
FROM cart_items ci
JOIN products p ON p.id = ci.product_id
WHERE ci.user_id = $1
ORDER BY ci.product_id
FOR UPDATE OF p`, userID)
if err != nil {
return nil, err
}
items, err := scanItems(rows)
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, ErrEmptyCart
}

Inside, part 2 — create the order

internal/adapter/storage/postgres/order.go — continue
var total int64
for _, i := range items {
total += i.PriceCents * int64(i.Quantity)
}
o = &Order{UserID: userID, TotalCents: total, Status: "placed"}
if err = tx.QueryRowContext(ctx,
`INSERT INTO orders (user_id, total_cents) VALUES ($1, $2)
RETURNING id, created_at`,
userID, total).Scan(&o.ID, &o.CreatedAt); err != nil {
return nil, err
}

Inside, part 3 — copy each line and take the stock

This is the most important code in the project, and the WHERE on the UPDATE is why.

internal/adapter/storage/postgres/order.go — continue
for _, i := range items {
if _, err = tx.ExecContext(ctx,
`INSERT INTO order_items (order_id, product_id, name, price_cents, quantity)
VALUES ($1, $2, $3, $4, $5)`,
o.ID, i.ProductID, i.Name, i.PriceCents, i.Quantity); err != nil {
return nil, err
}
// THE IMPORTANT LINE: only succeeds if there is still enough stock.
res, uErr := tx.ExecContext(ctx,
`UPDATE products SET stock = stock - $1
WHERE id = $2 AND stock >= $1`,
i.Quantity, i.ProductID)
if uErr != nil {
err = uErr
return nil, err
}
if n, _ := res.RowsAffected(); n == 0 {
err = fmt.Errorf("%w: %s is out of stock", ErrOutOfStock, i.Name)
return nil, err // the deferred rollback undoes everything above
}
}

Inside, part 4 — empty the cart

internal/adapter/storage/postgres/order.go — finish
if _, err = tx.ExecContext(ctx,
`DELETE FROM cart_items WHERE user_id = $1`, userID); err != nil {
return nil, err
}
return o, tx.Commit()
Only now does anything become real. Until Commit runs, every statement above can still be undone.

The line that prevents overselling

Look closely at WHERE id = $2 AND stock >= $1. The check and the change are the same statement, so nothing can happen between them.

Do it the other way (read the stock without a row lock, check it in Go, then update) and two checkouts can both read "1 left", both decide it is fine, and both subtract. That gap is a race condition. Putting the condition inside the UPDATE removes it.

RowsAffected() == 0 is then how you find out somebody else got there first.

Note: FOR UPDATE and the conditional UPDATE overlap on purpose. The lock makes concurrent checkouts queue; the WHERE keeps the stock rule true even if a later change drops the lock. ORDER BY ci.product_id makes every checkout take its locks in the same order, so two of them cannot deadlock.

Transaction rules

  • Keep them short. A transaction holds a connection from the pool for its whole life.
  • Never call an external service inside one. A slow payment provider would hold a database connection and its row locks, and with enough of them, use up the pool.
  • Always use `tx`, never `db`, for every statement inside.
  • Lock rows in a consistent order everywhere in your codebase, or two transactions can deadlock.

Prove it works

terminal
# set stock to exactly 1
docker exec -it shop-pg psql -U postgres -d shop \
-c "UPDATE products SET stock = 1 WHERE id = 1;"
# two customers both put it in their cart
curl -s -X POST localhost:8080/v1/cart/items -H "Authorization: Bearer $CUST" -d '{"product_id":1,"quantity":1}'
curl -s -X POST localhost:8080/v1/cart/items -H "Authorization: Bearer $CUST2" -d '{"product_id":1,"quantity":1}'
# both check out at the same moment
curl -s -X POST localhost:8080/v1/checkout -H "Authorization: Bearer $CUST" &
curl -s -X POST localhost:8080/v1/checkout -H "Authorization: Bearer $CUST2" &
wait
# exactly one order, and stock is 0 — never -1
docker exec -it shop-pg psql -U postgres -d shop \
-c "SELECT stock FROM products WHERE id=1; SELECT count(*) FROM orders;"
Run it several times. A race that only shows up sometimes is still a race.
The short version
  • A transaction makes several statements all-or-nothing. Checkout is the textbook case.
  • Put the condition inside the UPDATE: WHERE id = $2 AND stock >= $1, then check RowsAffected.
  • Use a named return so the deferred rollback can see whether an error happened.
  • Keep transactions short, never call an external service inside one, and lock rows in a consistent order.
Remember: Checking a value in Go and then changing it in SQL leaves a gap. Put the check in the WHERE clause and the gap disappears.
Build this before moving on
Build checkout as one transaction, then run the two-customers-one-item test several times. Stock must never go negative and exactly one order must exist.
Then try these in your own editor
  1. 1Remove AND stock >= $1 and run the race test again. Work out what still stops stock going negative.
  2. 2Force an error after the order insert and confirm the whole thing rolls back.
  3. 3Add a time.Sleep inside the transaction and watch the connection pool while sending several checkouts.
  4. 4Change one query to use r.db instead of tx and work out exactly what breaks.
Check yourselfTake the Project 3 — Small shop test