A single add_foreign_key
in a Rails migration can obliterate a dependent table
in your SQLite database. This is exactly what happened with my self-hosted error
tracker, Telebugs.
I added a new foreign key to the error groups
table like I normally would,
then deployed my test instance and saw that all individual reports (those that
belong to error groups) were gone. Completely. My face was like WTF (if that
could be a face).
The migration was part of a larger set, but I narrowed it down to this:
class AddOwnerToGroups < ActiveRecord::Migration[8.0]
def change
add_foreign_key :groups, :users, column: :owner_id, on_delete: :nullify
end
end
So what’s going on? It’s all about how Rails and SQLite handle schema changes. My schema had a foreign key from reports
to groups
with on_delete: :cascade
:
add_foreign_key "reports", "groups", on_delete: :cascade
When I added the new foreign key to groups
, SQLite (via Rails) needed to
recreate the groups
table because SQLite doesn’t support ALTER TABLE
for
adding foreign keys. The process goes like this:
- Create a temporary table (
agroups
) with the new schema, including the foreign key. - Copy data from
groups
toagroups
. - Drop the original
groups
table. - Recreate
groups
with the new schema. - Copy data back from
agroups
togroups
.
Do you see the problem? When the original groups
table is dropped, the reports
table’s on_delete: :cascade
foreign key kicks in. Since reports.group_id
temporarily points to non-existent groups.id
values, SQLite deletes all reports
records 💀
How I fixed it
I rewrote the migration to avoid add_foreign_key
:
class AddOwnerToGroups < ActiveRecord::Migration[8.0]
def change
add_column :groups, :owner_id, :integer
add_index :groups, :owner_id
end
end
This adds the owner_id
column and index without touching the table structure,
so no data is lost. To enforce the foreign key constraint, I added a validation
in my Group
model:
class Group < ApplicationRecord
belongs_to :owner, class_name: 'User', optional: true
validates :owner_id, inclusion: { in: ->(record) { User.pluck(:id) + [nil] } }, allow_nil: true
end
This ensures owner_id
is either nil
or a valid User.id
, mimicking the
database foreign key without SQLite’s pitfalls.
Lessons learned
- SQLite gotchas: SQLite’s table recreation for schema changes can wreak
havoc with cascading foreign keys. Be cautious with
on_delete: :cascade
in migrations. - Safer migrations: Use
add_column
andadd_index
for simple column additions, and enforce constraints at the application level when using SQLite. - Avoid cascading deletes: Consider using
on_delete: :nullify
for foreign keys likereports
togroups
to prevent data loss, though you’ll need to handle orphanedreports
(withgroup_id: nil
) in your application logic. - Test with data: Always test migrations with realistic data, including invalid foreign key values, to catch issues early.
- Backup first: Before running migrations, back up your database (
sqlite3 db/production.sqlite3 ".backup backup.sqlite3"
) to avoid data loss.
Do I still love SQLite? But of course! However it comes with gotchas that you must know to use it efficiently and avoid landmines.