Updated October 2026: Added # frozen_string_literal: true and Ruby 3.4 chilled strings, fixed the select!/keep_if comparison 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 nil when 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’TSHOULD BE
Enumerable#reverse.eachEnumerable#reverse_each
Enumerable#select.firstEnumerable#detect

READ MORE

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.