Step 6 — Checkout, and what a transaction is for
Five things must all happen, or none of them. This is what transactions exist for.
- Write a checkout transaction with a deferred rollback.
- Lock product rows with
SELECT ... FOR UPDATEto 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
| Problem | What goes wrong without it | The fix |
|---|---|---|
| Partial failure | stock drops but no order exists | one transaction around everything |
| Two people, one last item | both succeed, stock goes to -1 | stock >= quantity in the UPDATE, then check RowsAffected |
| Prices change mid-checkout | the order total does not match what was charged | copy the price onto the order line |
The transaction, with the rollback that always runs
// 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()}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.
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
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.
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
if _, err = tx.ExecContext(ctx, `DELETE FROM cart_items WHERE user_id = $1`, userID); err != nil { return nil, err } return o, tx.Commit()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.
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
# set stock to exactly 1docker 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 cartcurl -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 momentcurl -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 -1docker exec -it shop-pg psql -U postgres -d shop \ -c "SELECT stock FROM products WHERE id=1; SELECT count(*) FROM orders;"- 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 checkRowsAffected. - 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.
- 1Remove
AND stock >= $1and run the race test again. Work out what still stops stock going negative. - 2Force an error after the order insert and confirm the whole thing rolls back.
- 3Add a
time.Sleepinside the transaction and watch the connection pool while sending several checkouts. - 4Change one query to use
r.dbinstead oftxand work out exactly what breaks.