Upgrading from Rails 5.2 to Rails 6 - Modern Rails Features

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 as then)
  • rescue in blocks without begin
  • 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.8

Important: 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 variants
bundle update rails
bundle install

Step 2: Run the Update Task

rails app:update

Review and merge changes carefully, especially:

  • config/application.rb
  • config/environments/*.rb
  • config/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
end

File 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
end

Rule 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
end

Rule 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
end

Check for Zeitwerk Issues

# Check for autoloading issues

rails zeitwerk:check

# This will report any naming mismatches

Common Zeitwerk Fixes

Issue 1: Misnamed files

# Find files that don't match their class names

# Manual review needed

find app -name "*.rb" -type f

Issue 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 }
end

Issue 3: Explicit requires

# Remove explicit requires in app/ directory

# WRONG

require 'user_service'

# CORRECT - let Zeitwerk handle it

# Just use the constant

UserService.new

Step 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.js

Migrate JavaScript

Option 1: Keep Sprockets (easier)

# We can keep using Sprockets for now

# app/assets/javascripts/application.js still works

# No immediate migration needed

Option 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

end

Use Case 2: Read Replicas (Advanced - Has Caveats)

Important: Read replicas can cause issues with session based authentication and OAuth.

Known Issues:

  1. Session Storage: Replication lag causes OAuth/authentication failures
  2. Stale Reads: Writes may not be immediately visible on replicas
  3. 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:migrate

Create 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

end

Step 7: Action Text (Optional)

Action Text brings rich text content and editing to Rails.

Install Action Text

rails action_text:install
rails db:migrate

Use 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 test

Step 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 test

Step 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
end

System 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
end

Step 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 errors

3. Node.js Version

Webpacker requires Node.js 10.13+:

node -v
# Should be >= 10.13


# Install with nvm

nvm install 14
nvm use 14

Upgrade 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:check and fix naming issues
  • Install Webpacker: rails webpacker:install
  • Fix Ruby 2.7 keyword argument warnings
  • Replace update_attributes with update
  • 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


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.

Contact us for Rails upgrade consulting

Need help on your Ruby on Rails or React project?

Join Our Newsletter