Migrating from Paperclip to ActiveStorage in Legacy Rails Applications
During the 1850s and 1860s, the city of Chicago had a problem. The city had been built on low-lying swamp land, leading to persistent drainage issues and public health crises. The solution was drastic: the entire city needed to be raised by several feet to install a new sewer system. What is remarkable about the raising of Chicago is not the engineering feat itself, but that it was done without interrupting daily life. Buildings were lifted using jackscrews while businesses inside remained open and people continued to come and go.
Similarly, when planning a Ruby and Rails upgrade, engineering teams inevitably encounter foundational dependencies that must be completely replaced without halting the application. For years, Thoughtbot’s Paperclip gem was the standard solution for file uploads in the Ruby ecosystem. Paperclip, though, was officially deprecated in 2018 in favor of Rails’ built-in ActiveStorage.
Operating a legacy application with deprecated file attachment libraries is a significant technical debt hotspot. It prevents migration to newer versions of Rails, introduces potential security vulnerabilities, and complicates server infrastructure. For large applications, a Paperclip to ActiveStorage migration is not a luxury; it is a required step for maintaining technical health. Much like the buildings in Chicago, we must swap out the foundation while the application continues to serve traffic.
Before we get into that, though, we must understand the architectural differences between the two systems. Paperclip is fundamentally an imperative, column-based attachment system. It adds discrete columns (like avatar_file_name and avatar_content_type) directly to your model’s database table. ActiveStorage, on the other hand, is a polymorphic system. It uses two dedicated tables (active_storage_blobs and active_storage_attachments) to manage all files across your entire application.
This structural difference means replacing the gem requires more than a single line change in your Gemfile. We must migrate both our codebase and our underlying data structures.
Installing ActiveStorage
First, we must configure our application to support ActiveStorage. This begins with installing the necessary database tables.
$ bin/rails active_storage:install
Copied migration 20241018000000_create_active_storage_tables.active_storage.rb from active_storage
$ bin/rails db:migrate
== 20241018000000 CreateActiveStorageTables: migrating ========================
-- create_table(:active_storage_blobs)
-> 0.0123s
-- create_table(:active_storage_attachments)
-> 0.0091s
..snip..
Running the above commands generates a migration that creates the active_storage_blobs, active_storage_attachments, and active_storage_variant_records tables. I’ve abbreviated the output for the sake of brevity. Strictly speaking, you only need the variant records table if you plan to transform images, but it is standard practice to create all three.
You also may notice that ActiveStorage requires a configuration file at config/storage.yml. This file defines your storage services, such as local disk storage for development and Amazon S3 or Google Cloud Storage for production.
local:
service: Disk
root: <%= Rails.root.join("storage") %>
amazon:
service: S3
access_key_id: <%= ENV['AWS_ACCESS_KEY_ID'] %>
secret_access_key: <%= ENV['AWS_SECRET_ACCESS_KEY'] %>
region: <%= ENV['AWS_REGION'] %>
bucket: <%= ENV['AWS_BUCKET'] %>
After defining your services, you must tell your environment which service to use by setting config.active_storage.service = :local in config/environments/development.rb, and similarly for production.
The Dual-Write Phase
There are two major approaches to migrating file attachments. The first is a “Big Bang” migration, where you pause the application, migrate all the data, and switch over to the new system. The second approach is the dual-write phase, where the application writes to both systems simultaneously.
Generally speaking, the first option is more straightforward and works well for small applications. The second option, though, will often make more sense if you have thousands or millions of user-uploaded files. For large applications, migrating attachments in a single deployment is exceptionally risky. Instead, we use the dual-write phase.
During this phase, we configure our models to write to both Paperclip and ActiveStorage simultaneously. This ensures that new uploads are captured by the new system while we migrate the historical data.
For example, if you had a User model with an avatar attached via Paperclip, you would add an ActiveStorage attachment alongside it. Because ActiveStorage will conflict with Paperclip if they share the exact same name, we typically prefix the new attachment.
class User < ApplicationRecord
# The legacy Paperclip definition
has_attached_file :avatar
# The new ActiveStorage definition
has_one_attached :new_avatar
after_commit :dual_write_avatar, on: [:create, :update]
private
def dual_write_avatar
return unless saved_change_to_avatar_file_name? && avatar.present?
# We copy the file from Paperclip's storage to ActiveStorage
file = File.open(avatar.path)
new_avatar.attach(
io: file,
filename: avatar_file_name,
content_type: avatar_content_type
)
end
end
Of course, downloading files from S3 to upload them back to S3 during a web request can cause performance bottlenecks. In a production environment, you should push this dual-write logic into a background job to ensure your web requests remain fast.
Migrating Historical Data
Once the dual-write mechanism is deployed, new uploads are safely stored in both systems. Next, we must migrate the historical records.
One may wonder: do we need to physically copy every file from the Paperclip S3 directory to the ActiveStorage S3 directory? The answer is straightforward: no, we do not. Copying terabytes of data is expensive and slow. Instead, we can write a script that generates ActiveStorage::Blob and ActiveStorage::Attachment records that point to the existing file paths in your S3 bucket.
Here is an example script that migrates Paperclip metadata to ActiveStorage without physically moving the files.
User.where.not(avatar_file_name: nil).find_each do |user|
next if user.new_avatar.attached?
# Construct the precise path where Paperclip stored the file
paperclip_path = "users/avatars/#{user.id}/original/#{user.avatar_file_name}"
# Create the blob pointing to the existing file
blob = ActiveStorage::Blob.create!(
key: paperclip_path,
filename: user.avatar_file_name,
content_type: user.avatar_content_type,
byte_size: user.avatar_file_size,
checksum: Digest::MD5.base64digest("legacy"),
service_name: 'amazon'
)
# Link the blob to the user record
ActiveStorage::Attachment.create!(
name: 'new_avatar',
record: user,
blob: blob
)
end
Notice the key and checksum fields in the ActiveStorage::Blob creation. The key represents the exact S3 object path that Paperclip previously uploaded; this is what allows ActiveStorage to reuse the existing file. The checksum field is required by ActiveStorage for integrity validation, but because we are not downloading the historical files to calculate their true MD5 hashes, we provide a placeholder string. This is a pragmatic compromise to avoid expensive data transfers.
By generating these database records, ActiveStorage becomes aware of the files exactly where Paperclip originally placed them.
We can jump into our Rails console to verify that the new_avatar acts exactly like a native ActiveStorage attachment, even though it points to Paperclip’s historical file path:
$ bin/rails console
irb(main):001:0> user = User.where.not(avatar_file_name: nil).first
irb(main):002:0> user.new_avatar.attached?
=> true
irb(main):003:0> user.new_avatar.blob.byte_size
=> 102450
Notice that new_avatar.attached? returns true. This is significant because it means we can interact with the legacy file through the modern ActiveStorage API without having physically copied the underlying asset.
The Final Cutover and Cleanup
After the data migration script finishes, you must verify that all records have corresponding ActiveStorage attachments. We recommend running comprehensive audits on your staging environment to ensure no records were missed.
Once verified, you execute the final cutover. This involves renaming the ActiveStorage attachment back to the original name, removing the Paperclip configuration from your models, and finally, removing the paperclip gem from your Gemfile.
You also may need to update your views and controllers to use ActiveStorage’s URL generation methods. For example, you would replace user.avatar.url with url_for(user.avatar).
Of course, we haven’t discussed migrating image processing logic or variants in this article. Paperclip relied heavily on ImageMagick and system-level tools, while ActiveStorage handles processing slightly differently through its variant system. Executing this fundamental data migration, though, is the necessary first step.
Removing legacy dependencies like Paperclip is about long-term sustainability. By migrating to a built-in framework standard, you reduce the complexity of future Rails upgrades and ensure your file attachment system remains stable for years to come.
Sponsored by Durable Programming
Need help maintaining or upgrading your Ruby on Rails application? Durable Programming specializes in keeping Rails apps secure, performant, and up-to-date.
Hire Durable Programming