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_attached returns nil for previously existing attachments
  • Paperclip columns (avatar_file_name, avatar_content_type) still populated
  • ActiveStorage active_storage_attachments table 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. 1.Install ActiveStorage and create tables:
  2. 2.```bash
  3. 3.bin/rails active_storage:install
  4. 4.bin/rails db:migrate
  5. 5.`
  6. 6.Add ActiveStorage attachment declarations:
  7. 7.```ruby
  8. 8.class User < ApplicationRecord
  9. 9.# Keep Paperclip columns during migration
  10. 10.# Remove these after migration is verified
  11. 11.has_attached_file :avatar, styles: { medium: "300x300>", thumb: "100x100>" }

# Add ActiveStorage attachment has_one_attached :avatar_v2 end ```

  1. 1.Create data migration to copy files:
  2. 2.```ruby
  3. 3.# db/migrate/20260409100000_migrate_paperclip_to_active_storage.rb
  4. 4.class MigratePaperclipToActiveStorage < ActiveRecord::Migration[7.1]
  5. 5.def up
  6. 6.User.find_each do |user|
  7. 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. 1.Run migration in a background job for large datasets:
  2. 2.```ruby
  3. 3.class PaperclipMigrationJob < ApplicationJob
  4. 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. 1.Clean up Paperclip columns after verification:
  2. 2.```ruby
  3. 3.# After confirming all attachments migrated
  4. 4.class RemovePaperclipColumns < ActiveRecord::Migration[7.1]
  5. 5.def change
  6. 6.remove_column :users, :avatar_file_name, :string
  7. 7.remove_column :users, :avatar_content_type, :string
  8. 8.remove_column :users, :avatar_file_size, :integer
  9. 9.remove_column :users, :avatar_updated_at, :datetime
  10. 10.end
  11. 11.end
  12. 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_attached instead of has_many_attached for single files to avoid complexity