Introduction
Paperclip has been deprecated since 2019 and is no longer maintained. Migrating to ActiveStorage is necessary for Rails 6+ compatibility and security, but the migration is complex because Paperclip stores files on disk with a different directory structure and naming convention. A naive migration can break all existing attachment URLs and make files inaccessible.
Symptoms
- After migration, existing image URLs return 404 Not Found
has_one_attachedreturnsnilfor previously existing attachments- Paperclip columns (
avatar_file_name,avatar_content_type) still populated - ActiveStorage
active_storage_attachmentstable is empty after migration - Users see broken image placeholders on existing content
Check Paperclip columns exist:
``ruby
# In Rails console
User.column_names.select { |c| c.include?('_file_name') || c.include?('_content_type') }
# => ["avatar_file_name", "avatar_content_type", "avatar_file_size", "avatar_updated_at"]
Common Causes
- Direct schema migration without data migration
- Paperclip file path structure differs from ActiveStorage
- ActiveStorage uses separate tables with polymorphic associations
- File storage service (S3, local) paths not mapped correctly
- Missing metadata migration step
Step-by-Step Fix
- 1.Install ActiveStorage and create tables:
- 2.```bash
- 3.bin/rails active_storage:install
- 4.bin/rails db:migrate
- 5.
` - 6.Add ActiveStorage attachment declarations:
- 7.```ruby
- 8.class User < ApplicationRecord
- 9.# Keep Paperclip columns during migration
- 10.# Remove these after migration is verified
- 11.has_attached_file :avatar, styles: { medium: "300x300>", thumb: "100x100>" }
# Add ActiveStorage attachment has_one_attached :avatar_v2 end ```
- 1.Create data migration to copy files:
- 2.```ruby
- 3.# db/migrate/20260409100000_migrate_paperclip_to_active_storage.rb
- 4.class MigratePaperclipToActiveStorage < ActiveRecord::Migration[7.1]
- 5.def up
- 6.User.find_each do |user|
- 7.next unless user.avatar_file_name.present?
# Paperclip default path: :rails_root/public/system/:class/:attachment/:id_partition/:style/:filename paperclip_path = Rails.root.join( "public/system/users/avatars", "000/#{user.id.to_i}/#{user.id}", "original", user.avatar_file_name )
next unless File.exist?(paperclip_path)
# Attach to ActiveStorage user.avatar_v2.attach( io: File.open(paperclip_path), filename: user.avatar_file_name, content_type: user.avatar_content_type )
puts "Migrated avatar for User #{user.id}" end end
def down # No-op: cannot easily reverse end end ```
- 1.Run migration in a background job for large datasets:
- 2.```ruby
- 3.class PaperclipMigrationJob < ApplicationJob
- 4.queue_as :default
def perform(class_name, start_id, batch_size = 100) klass = class_name.constantize klass.where(id: start_id..(start_id + batch_size)).find_each do |record| next unless record.respond_to?(:avatar_file_name) && record.avatar_file_name.present?
file_path = build_paperclip_path(record) next unless File.exist?(file_path)
record.avatar_v2.attach( io: File.open(file_path), filename: record.avatar_file_name, content_type: record.avatar_content_type ) end end
private
def build_paperclip_path(record) Rails.root.join( "public/system", record.class.name.underscore.pluralize, "avatars", "000/#{record.id.to_i}/#{record.id}", "original", record.avatar_file_name ) end end
# Enqueue in chunks (0..User.maximum(:id)).step(1000).each do |start_id| PaperclipMigrationJob.perform_later("User", start_id, 100) end ```
- 1.Clean up Paperclip columns after verification:
- 2.```ruby
- 3.# After confirming all attachments migrated
- 4.class RemovePaperclipColumns < ActiveRecord::Migration[7.1]
- 5.def change
- 6.remove_column :users, :avatar_file_name, :string
- 7.remove_column :users, :avatar_content_type, :string
- 8.remove_column :users, :avatar_file_size, :integer
- 9.remove_column :users, :avatar_updated_at, :datetime
- 10.end
- 11.end
- 12.
`
Prevention
- Test migration on a staging database with production data snapshot
- Run migration in small batches with progress monitoring
- Keep Paperclip gem and columns until migration is fully verified
- Set up URL redirects from old Paperclip paths to new ActiveStorage URLs
- Monitor disk space during migration (files are temporarily duplicated)
- Use
has_one_attachedinstead ofhas_many_attachedfor single files to avoid complexity