Find dead columns, dead tables, unused indexes and other schema dead-weight in Rails / ActiveRecord apps — then remove them safely.
schema_reaper reads your live PostgreSQL schema and planner statistics and
cross-references them against a static scan of your codebase (Ruby via the
Prism AST, plus views and SQL string literals). Optionally it also fuses in a
runtime signal — a sampled log of which columns are actually read in
production. Every finding is scored by confidence and severity, carries an
estimate of the disk it reclaims, and comes with a concrete fix.
# Gemfile
gem "schema_reaper", group: :developmentbundle install
Requires Ruby >= 2.7 and PostgreSQL. The database connection is resolved in this order:
database_url:in.schema_reaper.ymlENV["DATABASE_URL"]config/database.ymlfor the current environment (SCHEMA_REAPER_ENV/RAILS_ENV, defaultdevelopment) — ERB and YAML aliases are handled, as are Rails 6+ multi-database sections
So in a Rails app, bundle exec schema_reaper scan works with no setup.
bundle exec schema_reaper scan # grouped terminal report
bundle exec schema_reaper scan --format markdown # PR-comment table
bundle exec schema_reaper scan --format sarif # GitHub code scanning
bundle exec schema_reaper scan --format json
bundle exec schema_reaper scan --ci # exit 1 on new findings
bundle exec schema_reaper scan --min-confidence 0.8
bundle exec schema_reaper scan --no-color # force plain output
bundle exec schema_reaper baseline # accept current findings
bundle exec schema_reaper trend # snapshot + progress delta
bundle exec schema_reaper generate-migration users legacy_api_token
The scan report rolls up findings that say the same thing about different
tables, then groups the rest by table and sorts by confidence:
schema_reaper 5 findings across 2 tables
missing_fk_index 3 · always_null_column 1 · dead_column 1
~93.8 KB reclaimable
users
█████ 90% medium missing_fk_index team_id
team_id is a foreign key with no covering index
→ add_index :users, :team_id
████░ 85% high always_null_column api_key 46.9 KB
pg_stats.null_frac = 1.0 across ~3000 row(s) · column carries no data
→ verify with `SELECT count(api_key) FROM users` then stage a removal
stale_exports
████░ 85% high dead_table
no model or query reference · table holds ~0 row(s)
→ confirm no external consumer, then `drop_table :stale_exports`
high 2 medium 1 low 2
Colour is automatic on a terminal, off when piped or NO_COLOR is set.
In a Rails app the railtie also gives you
rake schema_reaper:scan|baseline|trend (with FORMAT=).
| type | what it flags | main signal |
|---|---|---|
dead_column |
column no code path references | static scan (+ runtime) |
dead_table |
table with no model/query reference | static scan + row count |
unused_index |
non-unique index, idx_scan = 0 |
pg_stat_user_indexes |
duplicate_index |
index that is a prefix of a wider one | schema shape |
missing_fk_index |
*_id / FK column with no index |
schema shape |
always_null_column |
null_frac = 1.0 — no data at all |
pg_stats |
single_value_column |
one distinct value on a large table | pg_stats |
Columns owned by common gems (devise, paper_trail, activestorage, actiontext, friendly_id, audited, pg_search, ahoy_matey, paranoia family) are whitelisted automatically when the gem is in your bundle.
Static analysis alone can't see metaprogrammed access, so dead_column
confidence is capped at 0.6 without runtime data. To lift the cap:
# config/initializers or manually
SchemaReaper::Runtime::Tracker.install!(
store: SchemaReaper::Runtime::Store.new(path: ".schema_reaper/runtime.jsonl"),
sample_rate: 0.05
)or, in Rails, boot with SCHEMA_REAPER_TRACK=1. Let it run in staging or
production for a couple of weeks. A column unseen in both code and
= 14 observed days of runtime data reaches ~0.9 confidence.
schema_reaper never drops anything itself. generate-migration emits a pair:
- Ignore — you add
self.ignored_columns += %w[col]to the model and deploy. Nothing is dropped. - Drop — run only after step 1 has soaked in production and nothing broke.
always_null_column / single_value_column fixes ask you to confirm with a
SELECT first.
# .github/workflows/schema_reaper.yml
- run: bundle exec schema_reaper scan --ci --format sarif > reaper.sarif
- uses: github/codeql-action/upload-sarif@v3
with: { sarif_file: reaper.sarif }Commit .schema_reaper/baseline.json so the job fails only when a change adds
new dead weight.
# lib/schema_reaper/analyzers/my_check.rb
class MyCheck < SchemaReaper::Analyzers::Base
SchemaReaper::Analyzers::Registry.register(self)
def call
schema.tables.filter_map { |t| ... finding(type: :my_check, table: t.name, ...) }
end
end# .schema_reaper.yml
require:
- lib/schema_reaper/analyzers/my_check.rb- Runtime verdict fusion for index and table findings
- Orphan-row and
schema.rb↔DB drift analyzers - Disk/$ reclaim from real
pg_total_relation_size - Mountable dashboard engine, trend charts
- MySQL adapter
The gem is free and complete for a single app. schema_reaper Pro adds the
team-scale layer: MySQL adapter, multi-database fan-out, orphan-row and
schema-drift analyzers, Slack/Jira/PR-comment reporters, real
pg_total_relation_size + $ estimates, scheduled scans with alerts, and a
mountable dashboard engine. See PRO.md. Waitlist / early access:
open an issue tagged pro.
schema_reaper is MIT and maintained in the open. If it saved you disk, money,
or a nasty migration, sponsor its development via the Sponsor button on the
repo.
bin/setup
bundle exec rake # rspec + rubocop
MIT.