How To Roll Back: Best Practices for Using Transaction Blocks

photo of Louis Davis

Louis Davis

8 May, 2024

Transaction blocks help provide security during extensive data updates. If one thing fails, it's as if none of the previous changes were made. Without transaction blocks, the records could become muddled between a pre-change and post-change state, making it difficult to manually revert and undo the changes before they cause further issues.

During some recent development work, I explored how transaction blocks get triggered, what happens once triggered, and how this can impact a user's experience.

How is a Transaction Block Triggered?

Imagine you have a ten-question quiz. After each answer is saved, we also update the quiz's status. Without a transaction block, we might still save the answer even if the quiz fails to update. This would result in all the answers being flagged as complete but not the quiz itself — causing sync issues.

However, with a transaction block wrapping the code, we would only update the answer if the quiz is also successfully updated, which helps us and the user keep everything in sync.

Inside a transaction block, you can't just fail; you have to fail loudly. A 'loud' error allows the transaction block to know something has gone wrong. This is one of the key differences between the .update, .create and .save methods versus their 'bang' (!) alternatives, e.g. .update!.

Using .update, the rest of the code runs even when the quiz update fails. The transaction block doesn't revert any previous changes or stop future changes from being performed:


quiz = Quiz.create(status: :started) # validates presence of started or completed as status
answer = Answer.create(value: '6')
	
ActiveRecord::Base.transaction do
	answer.update(value: '4')
	quiz.update(status: nil) # not valid
end

puts answer.value
4

puts quiz.status
:started

# NB: Answer has updated, but Quiz has not

However, with .update! when the quiz update fails, it raises an error, reverting any previous database changes and stopping any latter lines of code inside the block from being run:


quiz = Quiz.create(status: :started) # validates presence of started or completed as status
answer = Answer.create(value: '6')
	
ActiveRecord::Base.transaction do
	answer.update!(value: '4')
	quiz.update!(status: nil) # not valid
end

puts answer.value
6

puts quiz.status
:started

# NB: Neither Answer nor Quiz have updated

Why Don't We Use Transaction Blocks All The Time?

Queries wrapped in transaction blocks take more database resources than single queries. A common mistake is to put a single query in a transaction block; this does not make sense because if the query fails, there is nothing to roll back.

Wrapping code unrelated to your database in a transaction is also a mistake. A transaction block will hold the database connection until the code inside the block executes. If you have limited database connections, this can cause timeouts to occur elsewhere.

When we error loudly in a transaction block, the executed code halts and rolls back. As a result, the user might receive a generic error page or no feedback.

The simplest way to avoid this user experience is to rescue it. While a transaction block captures the error, it doesn't act on it (bar rolling it back).

A common way to rescue a transaction block is by redirecting the user and giving them a helpful error message. Doing our own rescue means the transaction block is no longer in charge of dealing with the error; it no longer automatically rolls back the changes made, so this must be triggered manually, too:


ActiveRecord::Base.transaction do
	Answer.create!(value: '6')
	Quiz.create!(status: :completed)
rescue
	raise ActiveRecord::Rollback # manually trigger rollback
	flash[:danger] = 'Something went wrong updating your quiz, try again.' # helpful message
	redirect_to quiz_path # redirect to useful page
end

Conclusion

Transaction blocks should only contain the code we need to undo if something goes wrong. We need to ensure that if the code errors, it does so loudly. To ease the user experience, we can rescue the error to provide helpful feedback.

Now that we have a more comprehensive understanding of transaction blocks, we can make great use of them.

Martin Dick

Martin Dick

Co-Founder, mmtm

Need help implementing transaction blocks?

For more than 10 years, we've helped businesses build, maintain and improve Ruby on Rails applications, supporting products used by hundreds of thousands of users.

If your app is handling complex data updates, tricky workflows, or frequent changes, I'd be happy to help you think through a safer path!

Book a call

Frequently Asked Questions

When should I use a Rails transaction block?

Use a Rails transaction block when several database updates must succeed or fail together. Common examples include creating related records, updating an order and payment status, importing data, or changing records that rely on each other. A single standalone query usually does not need its own transaction block.

Why use bang methods like update! inside a transaction?

Bang methods such as update!, save! and create! raise an error when validation fails. That error tells the transaction something went wrong, so Rails can roll back earlier changes. Non-bang methods may simply return false, which can leave the transaction thinking everything completed successfully.

How should errors be handled in a Rails transaction block?

Errors should be handled deliberately. In many cases, you can rescue the exception, roll back the transaction, and show the user a helpful message rather than a generic error page. The key is making sure the rollback still happens when the transaction fails.

Do Rails transaction blocks affect performance?

They can. Transaction blocks keep a database connection open while the code inside runs, so they should only wrap the database work that genuinely needs to be rolled back. Avoid putting unrelated code, slow external calls, or unnecessary single queries inside a transaction.


Not Sure What To Read Next? Here's What We Recommend...

  • Ruby on Rails
  • Database Management
  • Web Development
  • Active Record
  • Transaction Blocks

Let's build something great together.