
You’ve probably written hundreds of Rails migrations by now. You’ve added columns, dropped tables, created indexes, and maybe even thrown in a few puts
statements to track what’s happening during those migrations. But here’s something that might surprise you: there’s a better way to communicate what your migrations are doing, and it’s been hiding in plain sight.
The say
method in Rails migrations isn’t just a fancy alternative to puts
. It’s a purpose-built tool that integrates seamlessly with Rails’ migration framework, providing cleaner output and better developer experience. Let’s explore why it matters and when you should reach for it instead of the trusty old puts
.
The Difference That Actually Matters
Using puts
inside a migration just prints raw text to STDOUT. That’s fine if you’re experimenting locally, but it quickly feels out of place during a deploy. Rails’ migration output is intentionally formatted: each step has a clear indentation and timing, so you can scan the log and know what happened. puts
ignores that.
Imagine a migration like this:
class AddIndexesToUsers < ActiveRecord::Migration[8.0]
def change
puts "Adding email index..."
add_index :users, :email
puts "Adding name index..."
add_index :users, :name
end
end
You’ll see those lines, but they won’t line up with the rest of the migration output.
Adding email index...
-- add_index(:users, :email)
-> 0.0023s
Adding name index...
-- add_index(:users, :name)
-> 0.0018s
Notice how the puts
messages don’t follow the same indentation or style as the rest of the log. They look like stray text dropped in the middle of structured output.
The equivalent example with say
looks like this:
class AddIndexesToUsers < ActiveRecord::Migration[8.0]
def change
say "Adding email index..."
add_index :users, :email
say "Adding name index..."
add_index :users, :name
end
end
Now the output matches the rest of the Rails migration log. Anyone scanning deploy logs will immediately see your messages as part of the structured flow, not as stray lines lost in the noise.
-- Adding email index...
-- add_index(:users, :email)
-> 0.0023s
-- Adding name index...
-- add_index(:users, :name)
-> 0.0018s
say_with_time: even better
A close cousin of say
is say_with_time
, which not only logs your message but also times the block you run. This is especially handy for long backfills or data corrections where you want to know how long something took.
say_with_time "Updating user email preferences" do
User.where(email_notifications: true).update_all(
preferences: { email: { newsletters: true, updates: true } }
)
end
When you run this migration, Rails will print:
-- Updating user email preferences
-> 0.1247s
This method gives you both the descriptive message and automatic timing information. It’s perfect for operations where you want to track performance without manually measuring execution time.
I’ve found this particularly useful when experimenting with different approaches to the same database optimization. The automatic timing helps identify which implementation performs better without cluttering the code with manual benchmarking.
Beyond Simple Messages
The say
method also supports different log levels through the subitem
parameter:
say "Processing user data migration"
say "Backing up existing data...", subitem: true
say "Transforming preferences...", subitem: true
say "Validating results...", subitem: true
Output:
-- Processing user data migration
-> Backing up existing data...
-> Transforming preferences...
-> Validating results...
This creates a hierarchical structure in your migration output, making it easier to follow complex multi-step processes. The indented output clearly shows which operations are part of a larger task.
A quick word of caution: schema vs data migrations
It’s tempting to put data changes directly inside migrations, but that can lead to trouble. Schema migrations are predictable and idempotent; data is not. If your migration tries to update millions of rows or depends on model code that changes over time, you risk blocking deploys or breaking old migrations entirely.
Some teams handle large data migrations with rake tasks, background jobs, or scripts instead of tying them to schema changes. If you do add data logic in a migration, keep it simple, fast, and safe. Avoid heavy business logic, consider batching, and use say_with_time
to log what’s happening. That way, your logs stay clear and your deploys stay smooth.
A Small Change With Big Impact
Using say
instead of puts
is a small habit shift that pays off in readability and maintainability. The next time you need to log progress inside a migration, give say
or say_with_time
a try. You’ll appreciate how much more natural your output feels. If you’ve got stories or tricks about migrations, leave a comment—we’d love to hear them. And if this was useful, don’t forget to give it some claps.