Rails 6 is a major milestone that modernizes Rails for the 2020s. The biggest change? Zeitwerk autoloader replaces the classic autoloader, requiring careful attention to file naming conventions.
Plus: Webpacker as default, multiple database support, parallel testing, Action Mailbox, and Action Text.
Note: This is Part 3 of our Rails Upgrade Series. Read Part 1: Planning and Part 2: Rails 4.2 to 5 first.
Before We Start
Expected Timeline: 2-4 weeks for medium sized applications
Medium-sized application: 20,000-50,000 lines of code, 30-100 models, moderate test coverage, 2-5 developers. Smaller apps may take 1-2 weeks, larger enterprise apps 6-12 weeks.
Prerequisites:
- Currently on Rails 5.2 (upgrade from 5.0/5.1 first)
- Ruby 2.5.0+ installed (Ruby 2.6 or 2.7 recommended)
- Test coverage of 80%+
- Understanding of app’s file structure
Ruby Version Requirements
Rails 6 requires Ruby 2.5.0 minimum, but we strongly recommend Ruby 2.6 or 2.7 for production.
Ruby Version Features
Ruby 2.5 (December 2017):
yield_self(later aliased asthen)rescuein blocks withoutbegin- Performance improvements
Ruby 2.6 (December 2018):
- Endless ranges (
1..) - Function composition (
<<,>>) - JIT compiler (experimental)
Ruby 2.7 (December 2019):
- Pattern matching (experimental)
- Numbered parameters (
_1,_2) - Keyword argument warnings (critical for Ruby 3.0)
Ruby Upgrade Path
# Check current Ruby version
ruby -v
# Install Ruby 2.7 (recommended)
rbenv install 2.7.8
rbenv local 2.7.8
# Verify
ruby -v
# => ruby 2.7.8Important: Ruby 2.7 will show keyword argument warnings. Fix these before upgrading to Ruby 3.0.
Step 1: Update the Gemfile
# Gemfile
# Update Rails
gem 'rails', '~> 6.0.0'
# Webpacker (new default for JavaScript)
gem 'webpacker', '~> 4.0'
# Bootsnap for faster boot times
gem 'bootsnap', '>= 1.4.2', require: false
# Update database adapters
gem 'pg', '>= 0.18', '< 2.0' # PostgreSQL
# or
gem 'mysql2', '>= 0.4.4' # MySQL
# Update common gems
gem 'sass-rails', '>= 6'
gem 'turbolinks', '~> 5'
gem 'jbuilder', '~> 2.7'
# Optional: New Rails 6 features
gem 'image_processing', '~> 1.2' # for ActiveStorage variantsbundle update rails
bundle installStep 2: Run the Update Task
rails app:updateReview and merge changes carefully, especially:
config/application.rbconfig/environments/*.rbconfig/initializers/new_framework_defaults_6_0.rb
Step 3: Zeitwerk Autoloader Migration
This is the biggest change in Rails 6. Zeitwerk is stricter about file naming and structure.
Enable Zeitwerk
# config/application.rb
module MyApp
class Application < Rails::Application
config.load_defaults 6.0
# Zeitwerk is now the default autoloader
# config.autoloader = :zeitwerk
end
endFile Naming Conventions
Zeitwerk requires strict adherence to naming conventions:
Rule 1: File names must match constant names
# WRONG
# app/models/userprofile.rb
class UserProfile < ApplicationRecord
end
# CORRECT
# app/models/user_profile.rb
class UserProfile < ApplicationRecord
endRule 2: Nested modules must match directory structure
# WRONG
# app/services/user_service.rb
module Users
class Service
end
end
# CORRECT
# app/services/users/service.rb
module Users
class Service
end
endRule 3: Acronyms must be consistent
# If we have API in the app
# config/initializers/zeitwerk.rb
Rails.autoloaders.main.inflector.inflect(
"api" => "API",
"html" => "HTML",
"json" => "JSON"
)
# Then:
# app/controllers/api/users_controller.rb
module API
class UsersController < ApplicationController
end
endCheck for Zeitwerk Issues
# Check for autoloading issues
rails zeitwerk:check
# This will report any naming mismatchesCommon Zeitwerk Fixes
Issue 1: Misnamed files
# Find files that don't match their class names
# Manual review needed
find app -name "*.rb" -type fIssue 2: Circular dependencies
# WRONG - circular dependency
# app/models/user.rb
class User < ApplicationRecord
has_many :posts
end
# app/models/post.rb
class Post < ApplicationRecord
belongs_to :user
validates :user, presence: true, if: -> { User.some_method }
end
# CORRECT - avoid referencing User in Post's class body
class Post < ApplicationRecord
belongs_to :user
validates :user, presence: true, if: -> { user.class.some_method }
endIssue 3: Explicit requires
# Remove explicit requires in app/ directory
# WRONG
require 'user_service'
# CORRECT - let Zeitwerk handle it
# Just use the constant
UserService.newStep 4: Webpacker Setup
Rails 6 uses Webpacker as the default JavaScript compiler.
Install Webpacker
# Install Webpacker
rails webpacker:install
# This creates:
# - app/javascript directory
# - config/webpacker.yml
# - babel.config.js
# - postcss.config.jsMigrate JavaScript
Option 1: Keep Sprockets (easier)
# We can keep using Sprockets for now
# app/assets/javascripts/application.js still works
# No immediate migration neededOption 2: Migrate to Webpacker (recommended)
// Move JavaScript from app/assets/javascripts to app/javascript
// app/javascript/packs/application.js
import Rails from "@rails/ujs"
import Turbolinks from "turbolinks"
import * as ActiveStorage from "@rails/activestorage"
import "channels"
Rails.start()
Turbolinks.start()
ActiveStorage.start()
// Import custom JavaScript files
// app/javascript/custom.js
import "./custom"
// Or organize in subdirectories
// app/javascript/components/navbar.js
import "./components/navbar"<!-- app/views/layouts/application.html.erb -->
<head>
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
<%= stylesheet_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
</head>Webpacker vs Sprockets
We can run both simultaneously during migration:
<!-- Keep both during transition -->
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>Step 5: Multiple Database Support (Optional)
Rails 6 adds native support for multiple databases. This is useful for separating concerns (e.g., analytics, reporting) but has important caveats.
Use Case 1: Separate Databases for Different Domains
# config/database.yml
production:
primary:
<<: *default
database: my_app_production
analytics:
<<: *default
database: my_app_analytics# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
connects_to database: { writing: :primary, reading: :primary }
end
# app/models/analytics_record.rb
class AnalyticsRecord < ActiveRecord::Base
self.abstract_class = true
connects_to database: { writing: :analytics, reading: :analytics }
end
# app/models/event.rb
class Event < AnalyticsRecord
# This model uses the analytics database
endUse Case 2: Read Replicas (Advanced - Has Caveats)
Important: Read replicas can cause issues with session based authentication and OAuth.
Known Issues:
- Session Storage: Replication lag causes OAuth/authentication failures
- Stale Reads: Writes may not be immediately visible on replicas
- Automatic Switching: Rails’ automatic role switching isn’t perfect
Solution for Session Issues:
# config/initializers/session_store.rb
# Use cookie store instead of database for sessions
Rails.application.config.session_store :cookie_store, key: '_myapp_session'
# Or use Redis (recommended for OAuth)
# gem 'redis-rails'
# Rails.application.config.session_store :redis_store, {
# servers: ["redis://localhost:6379/0/session"],
# expire_after: 90.minutes
# }Recommendation: Start with separate databases for different domains (analytics, reporting). Avoid read replicas unless we have proper monitoring and understand the trade offs.
Step 6: Action Mailbox (Optional)
Action Mailbox routes incoming emails to controller-like mailboxes.
Install Action Mailbox
rails action_mailbox:install
rails db:migrateCreate a Mailbox
# app/mailboxes/support_mailbox.rb
class SupportMailbox < ApplicationMailbox
before_processing :require_valid_sender
def process
# Create support ticket from email
SupportTicket.create!(
subject: mail.subject,
body: mail.body.decoded,
sender: mail.from.first
)
end
private
def require_valid_sender
bounce_with SupportMailbox::InvalidSender unless valid_sender?
end
def valid_sender?
mail.from.first.match?(/.*@example\.com/)
end
end# config/routes.rb
Rails.application.routes.draw do
# Route emails to mailbox
# [email protected] -> SupportMailbox
endStep 7: Action Text (Optional)
Action Text brings rich text content and editing to Rails.
Install Action Text
rails action_text:install
rails db:migrateUse Action Text
# app/models/post.rb
class Post < ApplicationRecord
has_rich_text :content
end<!-- app/views/posts/_form.html.erb -->
<%= form_with model: @post do |form| %>
<%= form.label :content %>
<%= form.rich_text_area :content %>
<%= form.submit %>
<% end %><!-- app/views/posts/show.html.erb -->
<%= @post.content %>Step 8: Parallel Testing
Rails 6 supports parallel test execution out of the box.
# test/test_helper.rb
class ActiveSupport::TestCase
# Run tests in parallel with specified workers
parallelize(workers: :number_of_processors)
# Or specify a number
# parallelize(workers: 4)
end# Run tests in parallel
rails test
# Disable parallel testing
PARALLEL_WORKERS=1 rails testStep 9: Breaking Changes
1. update_attributes Removed
# WRONG - removed in Rails 6
user.update_attributes(name: "John")
# CORRECT
user.update(name: "John")2. where.not with Multiple Conditions
# Rails 5 - multiple conditions in single where.not
User.where.not(role: nil, active: true)
# SQL: WHERE NOT (role IS NULL AND active = TRUE)
# Returns users where EITHER role is not nil OR active is not true
# Rails 6 - chain multiple where.not for AND logic
User.where.not(role: nil).where.not(active: true)
# SQL: WHERE role IS NOT NULL AND active != TRUE
# Returns users where role is not nil AND active is not true
# To get Rails 5 behavior in Rails 6, use OR
User.where.not(role: nil).or(User.where.not(active: true))
# SQL: WHERE (role IS NOT NULL) OR (active != TRUE)3. insert_all and upsert_all
New bulk insert methods:
# Bulk insert (skip validations, much faster)
User.insert_all([
{ name: "John", email: "[email protected]" },
{ name: "Jane", email: "[email protected]" }
])
# SQL: INSERT INTO users (name, email) VALUES ('John', '[email protected]'), ('Jane', '[email protected]')
# Returns: ActiveRecord::Result with inserted records info
# Bulk upsert (insert or update on conflict)
User.upsert_all([
{ id: 1, name: "John Updated" },
{ name: "New User", email: "[email protected]" }
], unique_by: :id)
# SQL: INSERT INTO users (id, name, email) VALUES (1, 'John Updated', NULL), (NULL, 'New User', '[email protected]')
# ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name
# Use unique_by to specify conflict column(s)
User.upsert_all([
{ email: "[email protected]", name: "John Smith" }
], unique_by: :email)4. Credentials Management
Rails 6 improves credentials with environment specific files:
# Edit credentials for specific environment
rails credentials:edit --environment production
# This creates:
# config/credentials/production.yml.enc
# config/credentials/production.key# Access credentials
Rails.application.credentials.secret_key_base
Rails.application.credentials.aws[:access_key_id]Step 10: Ruby 2.7 Keyword Arguments
Ruby 2.7 shows warnings for keyword argument changes coming in Ruby 3.0.
Common Warning
# This shows a warning in Ruby 2.7
def create_user(name:, email:)
User.create(name: name, email: email)
end
# Called with hash (warning)
options = { name: "John", email: "[email protected]" }
create_user(options)
# warning: Using the last argument as keyword parameters is deprecated
# CORRECT - use double splat
create_user(**options)Fix Keyword Argument Warnings
# Run tests with warnings enabled
ruby -w bin/rails test
# Or set environment variable
export RUBYOPT='-W:deprecated'
rails testStep 11: Testing Updates
Controller Tests
# Rails 6 controller tests
class UsersControllerTest < ActionDispatch::IntegrationTest
test "should get index" do
get users_url
assert_response :success
end
test "should create user" do
assert_difference('User.count') do
post users_url, params: { user: { name: "John", email: "[email protected]" } }
end
assert_redirected_to user_url(User.last)
end
endSystem Tests
# test/system/users_test.rb
require "application_system_test_case"
class UsersTest < ApplicationSystemTestCase
test "creating a user" do
visit users_url
click_on "New User"
fill_in "Name", with: "John"
fill_in "Email", with: "[email protected]"
click_on "Create User"
assert_text "User was successfully created"
end
endStep 12: Performance Improvements
After upgrading, we should see:
- Faster boot times with Bootsnap
- Better memory usage with Zeitwerk
- Parallel testing reduces test suite time by 50-70%
- Ruby 2.7 performance improvements
Common Gotchas
1. Zeitwerk and Concerns
# app/models/concerns/taggable.rb
module Taggable
extend ActiveSupport::Concern
included do
has_many :tags
end
end
# File name must match module name exactly
# taggable.rb -> Taggable (correct)
# Taggable.rb -> Taggable (wrong on case sensitive systems)2. Webpacker Compilation
# Precompile assets for production
RAILS_ENV=production rails assets:precompile
# This now includes Webpacker
# Check for compilation errors3. Node.js Version
Webpacker requires Node.js 10.13+:
node -v
# Should be >= 10.13
# Install with nvm
nvm install 14
nvm use 14Upgrade Checklist
Note: This checklist covers the most common changes. Depending on the application’s gems, custom code, and architecture, we may encounter additional issues. Always test thoroughly in a staging environment.
- Upgrade Ruby to 2.5+ (2.7 recommended)
- Update Gemfile with Rails 6.0
- Run
rails app:update - Run
rails zeitwerk:checkand fix naming issues - Install Webpacker:
rails webpacker:install - Fix Ruby 2.7 keyword argument warnings
- Replace
update_attributeswithupdate - Update credentials if needed
- Enable parallel testing
- Run full test suite
- Test in staging environment
- Deploy to production
What’s Next
We’ve successfully upgraded from Rails 5.2 to Rails 6 with Zeitwerk and Webpacker. In the next post, we’ll tackle Rails 6 to Rails 7, which brings Import Maps and Hotwire for a modern frontend without Node.js.
Coming up:
- Part 4: Rails 6.1 to Rails 7 - Import maps, Hotwire, Ruby 3, encrypted attributes
- Part 5: Rails 7.2 to Rails 8 - Solid Queue, authentication generator, Ruby 3.3
Resources
- Official Rails 6.0 Release Notes
- Rails Upgrade Guide
- Zeitwerk Documentation
- Webpacker Documentation
- RailsDiff 5.2 to 6.0
At Saeloun, we’ve helped numerous teams navigate the Zeitwerk migration and modernize their Rails applications. If expert guidance is needed with a Rails 6 upgrade, we’re here to help.
