Every Rails project has at least one of these. A model with an old column that's probably not used anymore. "Probably" is the scary part. If something in production is still referencing it, deleting the column breaks the app. NoMethodError, in production.
You know what that looks like. It's 11:30 PM the night before sprint planning. You're tidying up the Article model — the kind of low-stakes cleanup you save for when nothing urgent is on fire. You spot summary in db/schema.rb. It doesn't appear in any recent ticket. The last commit touching it was fourteen months ago. You look at the column definition: t.text :summary. Probably a description field from some old feature. You open the controller. You don't see it used. You check the views quickly. Nothing obvious.
You think: this is probably safe to delete. You run the migration, deploy, go to sleep.
At 7:15 AM your on-call pager fires. Five hundred errors per minute. NoMethodError: undefined method 'summary' for an instance of Article. Users are getting blank pages. Logs are flooding. Slack has twelve messages: "site down?" "was it that deploy last night?" Your stomach drops. You push a rollback. The errors stop. Now you spend the morning in a postmortem figuring out that an admin reporting feature — a rarely-used export endpoint buried in app/reports/ — was still calling article.summary to build a CSV. Nobody thought to check it. You didn't even know that file existed. The column wasn't unused; it just looked unused.
That's why nobody deletes anything: you can't be sure, so you don't. It's a reasonable call — except there was never a way to get sure.
The obvious thing to try: search for the column name in VS Code. In one Rails project, searching for summary returned 3,847 results. I started going through them and quickly noticed: almost none were the real thing. <summary> tags in ERB templates — the HTML accordion element. Translation keys in locale files. Description strings in RSpec examples. Actual code accessing article.summary: 9 results.
I gave up somewhere around result 50.
Why full-text search isn't enough
VS Code search and grep answer "does this string appear anywhere in this file?" That's useful for a lot of things. But when you want to know "is this column actually referenced in code?", text search picks up way too much. The column name in a string literal, in a comment, as an HTML tag name: it all counts as a hit. Sorting through them is manual work.
Those 3,847 VS Code results were full-text matches. Narrowing with grep -rn "\bsummary\b" --include="*.rb" to Ruby files left 847. Here's what those broke down to:
| Type | Count |
|---|---|
<summary> tags in ERB templates (HTML element) |
312 |
Translation keys in locale files (summary:, etc.) |
218 |
Description strings in RSpec describe / it blocks |
157 |
| Comments, variable names, unrelated strings | 151 |
| Actual column accesses | 9 |
summary is particularly tricky because HTML5 has a <details>/<summary> accordion element. If your project uses that tag in templates, every view file is a potential hit. To text search, <summary> and article.summary are the same thing: a string match.
Even filtering to Ruby files, any 'summary' string in a serializer field list or a comment still hits. "Ruby files only" and "actual column access" are completely different things.
The more you refine the regex, the more you start wondering whether the regex itself is missing something. You end up needing to verify the verification.
And summary is not even the worst case. Consider status, name, or type — column names that appear in dozens of unrelated contexts throughout a typical Rails project. Variable names, hash keys, RSpec subject descriptions, i18n keys, FactoryBot attributes. Hundreds of hits. Same problem, worse noise.
Try it right now if you're curious. Open a Rails project you've been on for a year. Run:
grep -rn "\bstatus\b" --include="*.rb" ./
Count the results. Most of them have nothing to do with the status column you're thinking about. They're local variables, hash keys in unrelated parts of the app, describe "updates the status" in test files. The signal you need — "is article.status actually accessed somewhere?" — is buried in hundreds of lines of noise.
There's also the psychological cost. You open VS Code, run the search, see 847 results for status, and your shoulders drop. You close the tab. You tell yourself you'll check it later. "Later" never comes. Nobody should have to hand-verify 847 results to answer a yes/no question.
"I searched, couldn't check everything, left it alone." Most Rails developers have been here. You want to delete the column but can't. Checking properly is possible, but it costs more time than the cleanup is worth. So the column stays, and they pile up.
So the columns accumulate. Here's what that does to a codebase.
The schema bloats. You open db/schema.rb and it's 600 lines. You scroll past columns you half-recognize — legacy_body, old_slug, deprecated_export_format, summary — added by developers who've since moved on, tied to tickets that closed eighteen months ago. Nobody knows what they did, so nobody touches them. Every SELECT * drags them along. Every Article.new builds an object with two dozen attributes, most of them nil because they've been unused for a year.
A new developer joins and runs Article.column_names to understand the schema, then stares at the output. "What's legacy_body? What does deprecated_export_format mean?" They ask in Slack. Nobody knows for certain, and the answer is "don't touch those." Reasonable in isolation. But repeat that across five models and it hardens into an unspoken rule: don't touch anything you didn't write.
Design options narrow with it. You want to add a content_format column, but you can't tell whether legacy_body and body are two competing implementations of the same concept or two genuinely different things. So the new feature gets bolted onto the side of the model instead of replacing the old thing cleanly. Every migration feels slightly riskier because the schema is full of things nobody understands, and the unease compounds until nobody touches anything at all. Six months later, another developer hits the same dead end.
This is the same kind of debt as missing tests: it accumulates quietly and you can never point to the moment it started. The real cost is cognitive load. Every unused column is a small tax on everyone who reads the model. Thirty columns, two years of new developers, one noisy on-call incident caused by a column that was supposed to be gone: that's how "we never clean up old columns" becomes a drag that's hard to measure and impossible to ignore. And none of it is a skills problem — the tool to check properly just didn't exist yet.
How colref reads code structure instead of text
What you actually wanted to know was: where is article.summary referenced in Ruby code? colref answers that, ignoring ERB <summary> tags and locale-file summary: keys.
How does it tell the difference? Instead of treating code as a sequence of characters, it reads the code structure.
When you write article.summary, Ruby sees "call the summary method on the article object" — a specific structure. <summary> is written as an HTML tag name — structurally, it's not a method call. :summary is written as a symbol. Reading code structure makes those differences detectable. Only places written as object.column_name get picked up. HTML tags, symbols, and strings that happen to contain summary are ignored.
Text search is Ctrl+F. Reading code structure is closer to a human reading through every line — except it handles thousands of lines in a second.
Here's what that looks like in practice:
| Method | Hits (for summary) |
What it sees |
|---|---|---|
| VS Code full-text search | 3,847 | All string matches |
grep \bsummary\b
|
847 | Word-boundary matches |
| colref | 9 | Actual column accesses only |
3,847 or 847 becomes 9. Whether you can act on the results depends entirely on how many there are.
When you get 9 results: open each one. app/controllers/articles_controller.rb:42 means go to that line and check whether article.summary is actually being accessed there. Nine results takes maybe 15 minutes.
A few things you'll encounter while reviewing results: the column appearing in a migration file (colref skips migrations, but if it surfaced it, the migration is just recording the column's history, not actively using it). You might also see test factories or fixtures that assign the column's value. If you delete the column and forget to clean up the factory, your test suite will fail. That's not a reason not to delete — it's just something to handle as part of the deletion.
When you get zero: you have a fact. "Not found in Ruby code" is different from "I think it's probably fine." It's the signal to move on: dynamic access patterns, templates, Strong Parameters, serializers. Treat a zero from colref as the first check, with several more still to run.
The shift is from "check 847 things" to "check 9 things, then a handful of specific files." That's the difference between a task you'll defer indefinitely and one you'll do today.
Installation
Add to your Gemfile:
bundle add colref --group development
Or install globally:
gem install colref
Specify the model name, field name, and your project directory:
colref check --orm rails --model Article --field summary ./
Results come back as filename:line_number. Each one is something you can open directly.
What zero results doesn't cover
Zero results doesn't mean "safe to delete." It means "not found in Ruby code," and the gap between those two is where columns come back to bite you.
Dynamic Access Pattern: colref detects literal-symbol forms like article.send(:summary) and article.read_attribute(:summary) — these appear in results with a [symbol] confidence label, meaning they need manual verification. What colref cannot catch is when the method name is stored in a variable:
# colref cannot catch this — field name is in a variable, not on the same call
[:title, :summary, :body].each do |attr|
puts article.send(attr)
end
The variable form can't be reliably caught with a single grep either — send.*summary only matches lines where send and summary appear together, which misses the loop above entirely. To find this pattern, read every send, public_send, and read_attribute call site directly. As a starting point:
grep -rn "send.*summary\|read_attribute.*summary" --include="*.rb" ./
This catches the literal form (article.send(:summary)) that colref already surfaces, and it's a useful double-check. But don't treat zero results as proof there's no variable-form usage — scan the call sites by eye for the loop pattern.
Symbol Permit Pattern: The column name appears as a symbol in permit inside controllers, which colref does not detect:
# controller
def article_params
params.require(:article).permit(:title, :summary)
end
colref won't detect this :summary symbol. Opening the controller file directly is the reliable check.
Serializer Field Pattern: Blueprinter, JSONAPI::Serializer, ActiveModelSerializers — all list fields as strings or symbols:
# Blueprinter
class ArticleBlueprint < Blueprinter::Base
fields :title, :summary
end
# JSONAPI::Serializer
class ArticleSerializer
include JSONAPI::Serializer
attributes :title, :summary
end
# ActiveModelSerializers
class ArticleSerializer < ActiveModel::Serializer
attributes :id, :title, :summary
end
Determining which model the :summary symbol in a list refers to requires tracing class inheritance, which colref doesn't handle yet. Serializer files tend to be few in number — opening them directly is the reliable check.
ERB templates: <%= @article.summary %> lives in .html.erb files. colref only scans .rb files. Check templates separately:
grep -rn "summary" --include="*.erb" ./
Note that <summary> tags will also hit here, so results will be noisy. Narrowing to @article.summary or article.summary is more practical.
ActiveAdmin / RailsAdmin: If you're displaying or editing the column in an admin interface, the reference is likely a string or symbol there too. I've seen column :summary sitting in an ActiveAdmin show block for a column that had been "confirmed removed" twice already — nobody checked the admin file because it only gets opened once a month. If your project uses either, check those files as well.
Checking serializers and admin files by eye sounds tedious, but in practice it takes a few minutes. These files tend to be organized by model. Open app/serializers/article_serializer.rb, find the relevant serializer, check the attributes list. Open app/admin/article.rb if you use ActiveAdmin. This isn't a grep problem. You open two files and look, and you're done — no tooling required.
colref (Ruby attribute accesses) + grep (variable dynamic patterns and templates) + manual check (Strong Parameters, serializers, admin) covers the vast majority of real-world Rails codebases. There are edge cases colref doesn't handle yet; the Detection Patterns docs list them. For most projects, this three-part check is enough to move from "I think it's probably unused" to "I have confirmed it's unused."
Once you've gone through all of that and colref returns zero, that's a grounded deletion rather than a guess.
The procedure
Here's the full sequence I run.
# 1. Check for column accesses in Ruby code
colref check --orm rails --model Article --field summary ./
# 2. Check for dynamic access (catches literal form; scan call sites for variable form)
grep -rn "send.*summary\|read_attribute.*summary" --include="*.rb" ./
# 3. Check ERB templates (narrow to .summary access)
grep -rn "\.summary" --include="*.erb" ./
# 4. Check Strong Parameters (controllers)
grep -rn ":summary\|'summary'" --include="*.rb" app/controllers/
# 5. Check serializers and admin
grep -rn ":summary\|'summary'" --include="*.rb" app/serializers/ app/admin/
# 6. Generate the removal migration
rails generate migration RemoveSummaryFromArticles summary:string
# 7. Apply to the schema
rails db:migrate
Steps 2–5 are still grep — colref doesn't solve everything. But step 1 cuts 847 results down to 9. The "too many results to check, left it alone" situation: this is the one place that changes.
One more thing about steps 6 and 7: give the migration a descriptive name like RemoveSummaryFromArticles. Six months from now, someone scanning migration filenames can see what changed and when without opening every file. Run the migration locally and make sure your test suite passes before deploying. When you're confident about a deletion it's tempting to skip verification. Don't. If a factory is still setting the deleted column, tests will catch it before production does.
The whole process — run colref, run the checklist, generate the migration, run tests locally, deploy — takes maybe 30 minutes for a column that's actually unused. Compare that to leaving it in db/schema.rb for another year because you couldn't confirm it was safe.
The difference between "I think it's probably unused" and "zero results in Ruby code, no dynamic send call sites, nothing in templates" matters when something goes wrong. Knowing what you checked tells you exactly where the cause wasn't — which narrows down where it was. A grounded deletion makes the debugging faster.
First run: try a column you know is used
If you don't have a deletion candidate in mind, start with a column you know is in use — something like title on Article:
colref check --orm rails --model Article --field title ./
If title is actively used, you'll get multiple results with file and line number:
app/controllers/articles_controller.rb:42
app/helpers/articles_helper.rb:11
app/serializers/article_serializer.rb:5
Seeing what a real result looks like makes it easier to judge zero results later. Then try a column you've been wondering about. Close to zero? Move to steps 2–5.
From installation to first run: under five minutes. Running it is faster than reading the README.
What do you do when you get 3 results? Open all three. For each one: is this code still running in production? If a reference is inside a clearly dead code path — wrapped in a feature flag that was turned off, or a method that's never called — it doesn't count as a real reference. If it's live code, the column is still in use. But 3 results is a manageable number. You can make that judgment call.
What if you get 0 results? Don't stop there. Run steps 2–5. Zero from colref, nothing from the dynamic access grep, clean templates, nothing in the controllers or serializers: that's multiple independent checks pointing the same direction. At that point you have something solid to stand on.
Even without a deletion candidate right now, colref fits into routine schema review. Scan db/schema.rb, spot something that looks unused, run colref. Zero results — it goes on the list. "Probably unused" becomes "not referenced in Ruby code" in 30 seconds. Do this periodically on projects you maintain. Every few months scan the migration history for columns you don't recognize, run colref on them, and build a short list. Some end up staying because they're used in ways colref doesn't detect. But a few always turn out to be genuinely gone: references removed over time, nobody noticed, nobody cleaned it up. Those get deleted.
FAQ
Can colref be added to CI?
Yes. The use case is catching references to a deleted column that sneak back in through someone's PR. colref check exits with code 0 when there are zero results, so it fits as a step in GitHub Actions or CircleCI:
- name: Check removed column references
run: colref check --orm rails --model Article --field summary ./
When something other than zero results comes back, the CI step fails. That prevents deleted column references from ever making it to main.
What should the team know before using this?
The important thing to communicate is what colref doesn't detect — Strong Parameters, serializers, ERB templates, and variable-symbol dynamic access. If "colref returned zero so it's safe" becomes the assumption without those additional checks, things get missed. Documenting a checklist alongside colref — "colref covers direct access in Ruby code; run these greps and open these files for the rest" — means new team members get the full picture from day one.
colref is still in development. If something doesn't work or you get unexpected results, open an issue at github.com/shinagawa-web/colref. Real usage feedback is what shapes the priorities.
colref currently supports Django and Rails. For the roadmap, see issue #74.
How many columns are you sitting on that you haven't been able to delete?
Top comments (0)