A bug doesn’t always need hundreds of lines of code to cause a real production problem.
Sometimes, one assumption is enough.
In this case, the assumption was simple:
“Every tag is a String.”
That assumption lived inside a Ruby filtering method in the open-source RubyEvents project.
It worked perfectly — until a tag wasn’t a String.
That small mismatch was enough to turn a normal user interaction into an error.
This is the story of how I tracked it down and fixed it.
The Bug
The issue was reported as:
“Cannot Click tags on Newsletter”
The behavior was straightforward:
- Open a newsletter.
- Click one of its tags.
- Instead of getting the filtered announcements, the application raises an error.
The issue was tracked as #1836.
At first glance, this sounds like a routing or controller problem.
It wasn’t.
The failure was deeper in the tag-filtering logic.
Finding the Assumption
The filtering logic lived in Announcement::Collection#by_tag.
The original implementation looked like this:
def by_tag(tag)
Collection.new(
select { |a| a.tags.map(&:downcase).include?(tag.downcase) }
)
end
Enter fullscreen mode Exit fullscreen mode
The intent is easy to understand:
- take all tags from an announcement
- convert them to lowercase
- compare them with the requested tag
- return matching announcements
For normal String values, this works perfectly.
For example:
["Ruby", "Rails"].map(&:downcase)
# => ["ruby", "rails"]
Enter fullscreen mode Exit fullscreen mode
But map(&:downcase) contains an important assumption:
Every element must respond to downcase.
That’s not necessarily true.
Imagine the collection contains:
["Ruby", 123]
Enter fullscreen mode Exit fullscreen mode
Calling:
["Ruby", 123].map(&:downcase)
Enter fullscreen mode Exit fullscreen mode
tries to execute:
123.downcase
Enter fullscreen mode Exit fullscreen mode
And integers don’t have a downcase method.
The result is a NoMethodError.
So the real problem wasn’t the newsletter link itself.
The newsletter was simply the path that exposed an unsafe assumption in the tag-filtering code.
The Interesting Part: Fixing the Bug Without Changing the Behavior
There were several ways this could have been “fixed.”
For example, we could have assumed that all tags should always be Strings and modified the data at the source.
But that would change the scope of the fix.
The filtering method already had a clear responsibility:
Find announcements whose tags match the requested tag, case-insensitively.
The safer approach was to make that comparison resilient to the actual values it receives.
I changed the implementation to:
def by_tag(tag)
Collection.new(
select { |a| a.tags.any? { |tag_value| tag_value.to_s.casecmp?(tag) } }
)
end
Enter fullscreen mode Exit fullscreen mode
There are two important changes here.
1. to_s creates a safe comparison boundary
Instead of assuming:
tag_value.downcase
Enter fullscreen mode Exit fullscreen mode
we explicitly convert the value:
tag_value.to_s
Enter fullscreen mode Exit fullscreen mode
Now a String remains a String:
"Ruby".to_s
# => "Ruby"
Enter fullscreen mode Exit fullscreen mode
And a non-String value becomes safely comparable:
123.to_s
# => "123"
Enter fullscreen mode Exit fullscreen mode
The filtering code no longer crashes simply because a tag value isn’t already a String.
2. casecmp? expresses the actual requirement
The original code lowercased both sides:
tag_value.downcase == tag.downcase
Enter fullscreen mode Exit fullscreen mode
But the actual requirement isn’t “convert everything to lowercase.”
The requirement is:
Compare these values without considering case.
Ruby provides exactly that operation:
casecmp?
Enter fullscreen mode Exit fullscreen mode
So the comparison becomes:
tag_value.to_s.casecmp?(tag)
Enter fullscreen mode Exit fullscreen mode
This makes the intent clearer.
We’re not transforming the data just to compare it.
We’re performing a case-insensitive comparison.
Why any? Instead of map?
This is another small but meaningful improvement.
The old implementation transformed every tag:
a.tags.map(&:downcase).include?(tag.downcase)
Enter fullscreen mode Exit fullscreen mode
The new implementation asks the question directly:
a.tags.any? { |tag_value| ... }
Enter fullscreen mode Exit fullscreen mode
We don’t actually need a transformed array.
We only need to know:
Does at least one tag match?
any? expresses that directly.
It also means we can stop looking as soon as a matching tag is found.
So the new implementation is not simply more defensive.
It is also closer to the intent of the operation.
Before vs After
Before
def by_tag(tag)
Collection.new(
select { |a| a.tags.map(&:downcase).include?(tag.downcase) }
)
end
Enter fullscreen mode Exit fullscreen mode
The hidden assumption:
Every tag
↓
must respond to #downcase
Enter fullscreen mode Exit fullscreen mode
If one doesn’t:
NoMethodError
↓
request fails
↓
user cannot follow the newsletter tag
Enter fullscreen mode Exit fullscreen mode
After
def by_tag(tag)
Collection.new(
select { |a| a.tags.any? { |tag_value| tag_value.to_s.casecmp?(tag) } }
)
end
Enter fullscreen mode Exit fullscreen mode
Now the comparison becomes:
Tag value
↓
convert safely to String
↓
case-insensitive comparison
↓
match / no match
Enter fullscreen mode Exit fullscreen mode
The existing filtering behavior remains intact while the unsafe type assumption is removed.
Why This Bug Is Easy to Miss
This is what I found most interesting about the issue.
The original code isn’t obviously bad.
For a dataset containing only Strings, it is perfectly reasonable Ruby code:
a.tags.map(&:downcase)
Enter fullscreen mode Exit fullscreen mode
The problem only appears when the runtime data doesn’t match the assumption made by the implementation.
That’s a common class of production bugs:
Code assumption
↓
"this value will always be a String"
↓
Works for normal data
↓
Unexpected value enters the system
↓
Runtime failure
Enter fullscreen mode Exit fullscreen mode
The lesson isn’t “never use downcase.”
The lesson is:
Know where your assumptions about data types are coming from.
If a method operates on data that can contain different types, the boundary where those values are consumed should be resilient.
The Final Change
The actual production change was intentionally small:
- Collection.new(select { |a| a.tags.map(&:downcase).include?(tag.downcase) })
+ Collection.new(select { |a| a.tags.any? { |tag_value| tag_value.to_s.casecmp?(tag) } })
Enter fullscreen mode Exit fullscreen mode
One line changed.
But that one line removed the assumption that every tag value is already a String.
I also fixed a linting issue in a follow-up commit.
The pull request was reviewed by the RubyEvents maintainer, passed all six checks, and was merged into the project’s main branch.
PR: #1847 — Fix tag filtering for non-string tags
Issue: #1836 — Cannot Click tags on Newsletter
What I Learned
1. Small bugs can expose bigger assumptions
The code wasn’t complicated.
The assumption behind the code was the real problem.
Whenever I see code such as:
items.map(&:some_method)
Enter fullscreen mode Exit fullscreen mode
I now ask myself:
Do I know for certain that every item responds to this method?
That question becomes particularly important at boundaries where data may come from different sources.
2. Fix the behavior, not just the exception
It would have been easy to focus only on preventing the NoMethodError.
But the goal should be broader:
- preserve existing behavior
- make the comparison safe
- make the intent clearer
- avoid unnecessary transformations
The final implementation does all four.
3. Ruby’s expressive methods can make intent clearer
Compare:
map(...).include?(...)
Enter fullscreen mode Exit fullscreen mode
with:
any? { ... }
Enter fullscreen mode Exit fullscreen mode
The second version tells the reader what we’re actually asking.
We’re not interested in producing another collection.
We’re asking whether any tag matches.
That distinction matters when maintaining code long after the original bug has been forgotten.
Open Source Contributions Are Full of These Bugs
One of the things I enjoy about contributing to open source is that you get to work with code outside the assumptions of your own applications.
You don’t know every historical decision behind a method.
You don’t know every shape of data that has passed through it.
And you don’t get to rewrite the whole system just because you found one imperfect assumption.
You have to understand the existing behavior, make the smallest responsible change, and prove that the fix doesn’t break what was already working.
That’s exactly what made this bug interesting to me.
The final diff was tiny.
The reasoning behind it was not.
The Takeaway
A production bug doesn’t always announce itself with a complicated stack trace or a thousand-line fix.
Sometimes it’s hidden inside a single assumption:
"Every tag is a String."
Enter fullscreen mode Exit fullscreen mode
When that assumption stopped being true, clicking a newsletter tag stopped working.
The fix was to make the comparison type-safe, preserve case-insensitive matching, and express the filtering intent more directly.
One line changed. One user-facing failure removed. One more reminder that robust software is often about handling the values we didn’t expect.