DEV Community

Cover image for What is a database transaction, and when do you reach for one in Rails?
Hassan Farooq
Hassan Farooq

Posted on

What is a database transaction, and when do you reach for one in Rails?

A transaction groups several database writes into one atomic unit. Either all of them commit, or none of them do. That is the guarantee you are buying: you never end up with half the work done and a database that contradicts itself.

People recite the full ACID list (atomicity, consistency, isolation, durability), but the property I actually reach for day to day is atomicity. All or nothing.

A real scenario: checkout

Placing an order looks like one action to the user, but under the hood it is several writes that all have to agree with each other.

Order.transaction do
  order = user.orders.create!(status: "pending")
  cart.items.each do |item|
    order.line_items.create!(product: item.product, quantity: item.quantity)
    item.product.decrement!(:stock, item.quantity)
  end
end
Enter fullscreen mode Exit fullscreen mode

Say the line items save fine but the stock decrement blows up halfway through the loop. Without a transaction I now have an order that sold three units of something while only deducting one from inventory. With the transaction, the exception rolls the whole block back and I am left in a clean state, as if the order never happened.

Gotchas I watch for

Rollback only fires on a raised exception. This is the one that bites people. create returns false on a validation failure, it does not raise, so the transaction happily commits everything else around it. Use the bang versions inside the block (create!, save!, update!) so a failure actually throws and triggers the rollback.

Don't rescue the exception inside the block. If you wrap the body in a begin/rescue and swallow the error, you have also swallowed the signal that tells the transaction to roll back, so it commits anyway. If you genuinely need to abort without an exception bubbling up to your callers, raise ActiveRecord::Rollback. It rolls back quietly and does not re-raise.

Keep slow external calls out of the transaction. An open transaction holds locks on the rows you have touched. If you put a Stripe charge or any HTTP request inside the block, you are holding those locks for the length of a network round trip. Under load that is how you get lock contention and a drained connection pool.

Why the Stripe charge specifically is risky

There are two problems. The first is the lock-holding one above. The second is worse: a charge is an external side effect, and you cannot roll it back. If the transaction commits and something downstream fails, or the transaction rolls back after Stripe already charged the card, your database and Stripe now disagree, and nothing reconciles that for you.

The pattern I use is to do the local database work inside the transaction and handle the charge outside it. Confirm the payment through webhooks and reconcile against them, so a retry never double-charges the customer.

Top comments (0)