Updated October 2026: Added
# frozen_string_literal: trueand Ruby 3.4 chilled strings, fixed theselect!/keep_ifcomparison and the printed outputs, and softened the parallel-assignment advice after re-measuring it.
I. Avoid Allocating a Lot of Objects
Example:
DON’T
1_000_000.times { "some string or object" }
SHOULD BE
my_invariant = "some string or object"
1_000_000.times { my_invariant }
OR
1_000_000.times { "some string or object".freeze }
OR, better, add the magic comment at the top of the file so every string literal in it is frozen (and deduplicated) automatically:
# frozen_string_literal: true
1_000_000.times { "some string or object" }
Since Ruby 3.4, string literals in files without this comment are “chilled”: they still work as mutable strings, but mutating one emits a deprecation warning (warning: literal string will be frozen in the future) when deprecation warnings are enabled with -W:deprecated or Warning[:deprecated] = true (Ruby 3.4 NEWS). Adding the magic comment now makes your code ready for frozen literals.
II. Do Not Use Exceptions for Control Flow
DON’T
def with_rescue
self.mythical_method
rescue NoMethodError
nil
end
SHOULD BE
def with_condition
respond_to?(:mythical_method) ? self.mythical_method : nil
end
III. Be Careful with Calculations within Iterators
DON’T
def func(array)
array.inject({}) { |h, e| h.merge(e => e) } # builds a new Hash on every iteration
end
SHOULD BE
def func(array)
array.inject({}) { |h, e| h[e] = e; h }
end
OR, more idiomatic:
def func(array)
array.each_with_object({}) { |e, h| h[e] = e }
end
# or
def func(array)
array.to_h { |e| [e, e] }
end
IV. Avoid Object Creation if Possible
1. String Concatenation
Avoid using += to concatenate strings in favor of the << method.
# frozen_string_literal: true
str1 = +"first" # unary + returns a mutable (unfrozen) copy
str2 = "second"
original_id = str1.object_id
DON’T
str1 += str2 # str1 = str1 + str2
str1.object_id == original_id # => false, a new object was created
SHOULD BE
str1 << str2
str1.object_id == original_id # => true, the same object was modified
When you use +=, Ruby creates a temporary object which is the result of str1 + str2. Then it overrides the str1 variable with a reference to the newly built object. On the other hand, << modifies the existing one.
As a result of using +=, you have the following disadvantages:
- More calculations to join strings.
- A redundant string object in memory (previous value of
str1), which approximates the time when GC will trigger.
Note that << needs a mutable string. With # frozen_string_literal: true, "first" << "second" raises FrozenError, so start from +"first" or String.new("first"). Without the magic comment, << on a literal triggers the Ruby 3.4 chilled-string warning described above.
2. Use Bang! Methods
In many cases, bang methods do the same as their non-bang analogs but without duplicating an object.
array = ['a', 'b', 'c']
array.map(&:upcase) # => ["A", "B", "C"], array is unchanged
array.map!(&:upcase) # array itself is now ["A", "B", "C"]
Two caveats:
- A bang method mutates the receiver, so every other reference to that object (a constant, a default argument, an object shared with the caller) sees the change. Only use them on objects you own.
- Many bang methods return
nilwhen nothing changed (select!,reject!,uniq!,upcase!, …), so don’t chain them or use their return value:
s = +"ABC"
s.upcase! # => nil, already upper case
Some methods mutate in place like bang methods, but without the !
2.1 select vs. keep_if
keep_if keeps the elements for which the block returns a truthy value and modifies the array in place. It is close to select!, but not an alias: keep_if always returns self, while select! returns nil when no element was removed.
a = %w[a b c d e f]
a.keep_if { |v| v.match?(/[aeiou]/) } # => ["a", "e"]
p a
# ["a", "e"]
a = %w[a b c d e f]
a.select { |v| v.match?(/[aeiou]/) } # => ["a", "e"], a new array
p a
# ["a", "b", "c", "d", "e", "f"]
2.2 reject vs. delete_if
reject returns a new array; delete_if removes the matching elements in place and always returns self (reject! does the same but returns nil when nothing was removed).
a = [1, 2, 3, 4, 5]
a.reject(&:even?) # => [1, 3, 5], a new array
p a
# [1, 2, 3, 4, 5]
a = [1, 2, 3, 4, 5]
a.delete_if(&:even?) # => [1, 3, 5]
p a
# [1, 3, 5]
3. Parallel Assignment
This was originally listed as “parallel assignment is slower”:
a, b = 10, 20
a = 10
b = 20
On a modern Ruby, a quick benchmark of the two forms shows a difference within measurement noise. Treat it as a micro-optimisation: prefer whichever reads better (parallel assignment is idiomatic for swaps like a, b = b, a), and measure with Benchmark before changing code for speed.
V. Use Methods That Are Optimized by Ruby
| DON’T | SHOULD BE |
|---|---|
| Enumerable#reverse.each | Enumerable#reverse_each |
| Enumerable#select.first | Enumerable#detect |
VI. Be Careful When Using eval and send
When you use them, please make sure you can handle what will be entered.
DON’T
type = params[:type] # type = "system('rm -rf ./')"
eval(type)
type = params[:type] # type = delete_all
User.send(type)
SHOULD BE
type = params[:type]
User.public_send(type) if ['abc', 'zyz'].include?(type)
Prefer public_send over send: send can also call private methods, while public_send respects visibility, so a value that slips through the allowlist still cannot reach private internals.
Public comments are closed, but I love hearing from readers. Feel free to contact me with your thoughts.