Migration Magic โจ, Handling Not-Null Constraints in Production with Ruby on Rails
Discover How to Handle Migrations in Production with Confidence ๐
Originally posted on my blog ahmednadar.com.
We've all been there-happily adding new tables and columns to our applications in the development environment. And when things go wrong, we drop the table and start fresh, no harm, no foul ๐. But what happens when we encounter issues in the production stage? That's when the challenge begins. In this post, we'll tackle a tricky migration scenario involving not-null constraints and share practical tips to avoid losing data in production. So buckle up, and let's dive in! ๐
๐ Development Stage Bliss
While i'm working on validateok.click application I needed to add a new column category
to an existing table ideas
. Here's an example migration:
class AddCategoryToIdeas < ActiveRecord::Migration[7.1]
def change
add_reference :ideas,
:category,
default: 'Tools',
null: false,
foreign_key: true,
type: :uuid
end
end
In the development environment, running this migration is a breeze, and everything works as expected. But what about production? ๐ฐ
๐ฌ Production Stage Hurdles
In production, dropping a database isn't an option unless we're willing to lose everything - and nobody wants that! ๐ฑ Using the same migration steps as we did in development above ๐๐ผ can lead to errors, like this one:
ActiveRecord::NotNullViolation: PG::NotNullViolation: ERROR: column "category_id" of relation "ideas" contains null values
The error occurs because the existing records in the ideas
table don't have a category_id
value, not only that, and we've set the null: false
constraint. What can we do to fix this issue? ๐ค
๐ช The Solution
No worries! We have the perfect solution for you๐ก. Let's walk through the steps needed to handle this migration gracefully in production.
Step 1: Remove the default value and split the migration into two parts
First, remove the default value 'Tools' from the migration, as it should be a UUID
, not a string. This is my mistake because I didn't pay attention that I'm using UUID. Am I the only one๐คฆโโ๏ธ Then, split the migration into two steps:
Add the
category_id
column without thenull: false
constraint and backfill the existing ideas with a default category.Finally, add the
null: false
constraint.
Here's the updated migration file:
class AddCategoryToIdeas < ActiveRecord::Migration[7.1]
def up
add_reference :ideas, :category, foreign_key: true, type: :uuid #1
default_category = Category.find_or_create_by!(name: 'Tools')
Idea.update_all(category_id: default_category.id)
change_column_null :ideas, :category_id, false #2
end
def down
remove_reference :ideas, :category
end
end
Step 2: Understand the purpose of each step
Add the
category_id
column without thenull: false
constraint. This allows us to update the existing records without violating the non-null constraint.Find or create a default category named 'Tools' and set its ID as the default
category_id
for all existing ideas. This ensures that all existing ideas have a validcategory_id
value.Add the
null: false
constraint to thecategory_id
column. Now that all existing ideas have been updated with a defaultcategory_id
, we can safely enforce the non-null constraint.And now you can confidently run
rails db:migrate
๐ Wrapping Up
And there you have it, folks! Thanks to these steps, we've gracefully handled a potentially tricky migration in the production environment without losing any data ๐. By splitting the migration into two parts, updating the existing records, and then enforcing the non-null constraint, we've made our application more robust and reliable.
Remember, as Rails developers, it's essential to think about the impact of our db migrations in both development and production environments. With a bit of vision, and a few well-placed emojis ๐, we can overcome these challenges and continue building fantastic applications.
As always, Happy Coding, and until next time! ๐