Assignment and Multiple Assignment

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Ruby 4.0 (4.0.7), checked on 2026-10-18. Assignment looks like the simplest thing in the language, yet it hides most of the rules that surprise newcomers: an assignment is an expression with a value, a local variable comes into existence when the parser sees it and not when the line runs, ||= is not the shorthand for a = a || b that most people believe, a setter without self silently creates a local variable, and the left side of = can be a whole destructuring pattern. This page covers every assignment form in the language: simple, abbreviated, multiple (with splats and nesting), attribute and index assignment, and rightward assignment with =>.

It builds on Variables, Constants and Scope (what each kind of variable name means and where it is visible) and Program Structure and Lexical Elements (how the parser reads a program). Later pages use it: Operators and Precedence (where = sits in the precedence table), Methods and Parameters (parameters destructure with the same rules), Blocks, Procs and Lambdas (block parameters), Arrays and Hashes (the containers you assign into and out of), and Pattern Matching (rightward assignment is a one-line pattern match).

Every example was checked with ruby -wc, run on Ruby 4.0.7 with warnings enabled (ruby -w), and linted with RuboCop using the default cops of the shelf gem configuration (target Ruby 4.0, new cops enabled, double-quoted strings); the output shown is the real output. Where an example intentionally breaks a cop (RuboCop dislikes parallel assignment that is not a swap), an inline rubocop:disable comment says so.

Assignment is an expression

In Ruby, = does not introduce a statement: target = value is an expression whose result is always the assigned value, and it can sit inside another expression. The documentation states that an assignment expression result is always the assigned value, including assignment methods (setter calls). The right side is evaluated first, the target is bound, and the value flows out.

# frozen_string_literal: true

title = +"Dune"
copy = title
copy << " (1965)"
puts title
p title.equal?(copy)

a = b = []
a << 1
p b

x = (y = 5) + 1
p [x, y]

Output:

Dune (1965)
true
[1]
[6, 5]

Source: Assignment

Three consequences follow from the example:

  • Chained assignment (a = b = []) is just two nested assignments, and both names refer to the same object. Assigning never copies; it binds a name to an object. Mutating the object through one name is visible through the other (see Object Lifecycle, Freezing and Copying for dup and clone).

  • An assignment can be used where a value is expected (x = (y = 5) + 1), but doing so hurts readability; RuboCop’s Lint/AssignmentInCondition flags if x = compute for the same reason.

  • title = +"Dune" is the idiomatic way to get a mutable string when the file has # frozen_string_literal: true: unary plus returns an unfrozen copy (and copy << ... then mutates the one object both names refer to).

Local variables are created by the parser

A local variable exists from the point where the parser sees an assignment to it, not from the moment the assignment runs. A name that has been assigned to earlier in the same scope is read as a variable; a name that has not is read as a method call.

# frozen_string_literal: true

ghost = 1 if ENV.fetch("NEVER_SET", nil)
p ghost

copies = nil
copies ||= 3
copies ||= 9
p copies

stock = { "Dune" => 2 }
stock["Dune"] += 1
stock["Emma"] ||= 0
stock["Emma"] += 4
p stock

flag = false
flag ||= true
flag &&= !flag
p flag

Output:

nil
3
{"Dune" => 3, "Emma" => 4}
false

The first two lines show the consequence: ghost = 1 if ENV.fetch("NEVER_SET", nil) never runs the assignment, yet ghost is a defined local variable whose value is nil, so p ghost prints nil instead of raising NameError. The remaining lines of the example are covered in the next sections. Two related rules follow:

  • p a if a = 0.zero? raises NameError (undefined local variable or method a): the parser reads the bare a on the left of the modifier if before it has seen the assignment to its right, so it takes a for a method call.

  • Because blocks do not start a new scope for names already visible outside them, a block that assigns to an existing outer variable changes that variable; a name first assigned inside a block is local to the block. def, class and module start a new scope that sees none of the outer locals. See Variables, Constants and Scope.

Variable or method: the setter trap

Local variable names and method names look the same, and assignment always wins: once the parser has seen name = ..., a bare name later in the scope is a variable. The mistake that bites most often is forgetting self on a setter, because value = 1 inside a method defines a local, never calls value=.

# frozen_string_literal: true

# Without self, an assignment inside a method creates a local variable.
class Shelf
  attr_accessor :label

  def broken
    label = "Fiction"
    [label, local_variables, @label]
  end

  def fixed
    self.label = "Fiction"
    [local_variables, @label]
  end
end

shelf = Shelf.new
p shelf.broken
p shelf.fixed

def total = 42

total = total.to_i
p total

def pages = 100

pages = pages()
p pages

Output:

["Fiction", [:label], nil]
[[], "Fiction"]
0
100

In the example broken returns ["Fiction", [:label], nil]: the local label was created, the instance variable was never set, and local_variables lists :label. fixed calls the writer through self.label = ..., so there are no locals and @label is set. When the local is never read afterwards, ruby -w warns assigned but unused variable, and RuboCop reports Lint/UselessAssignment; that warning is usually the first symptom of this mistake. Two more details:

  • total = total.to_i where total is also a method (it returns 42): the right side total is already a local (the parser registered the assignment before reading the right side), so it is nil and the method is never called; nil.to_i gives 0, not 42. Write pages = pages() (empty parentheses force a method call, and the example prints 100) or use an explicit receiver such as self.pages.

  • A setter is the one place self. is mandatory even though it is a public method. Reading (self.label) does not need it; only assignment does, as does self.label += 1 (the read and the write are both explicit calls).

Abbreviated (operator) assignment

target op= value combines a binary operator with assignment. The operators that can be used this way are

+  -  *  /  %  **  &  |  ^  <<  >>

plus the two logical forms ||= and &&=. They expand as follows:

Form Meaning Typical use

arithmetic and bitwise forms (counter += 1)

counter = counter + 1: the operator is a method call on the current value

counters, accumulators, flags (mask |= 4)

a ||= b

a || (a = b): assign only if a is nil or false

defaults, memoization

a &&= b

a && (a = b): assign only if a is truthy

transform a value that may be absent

a.x ||= b and a[i] ||= b

the receiver (and index arguments) are evaluated once; the reader and then, only if needed, the writer are called

lazy defaults on attributes and hashes

The documentation makes the key point explicitly: the logical operators behave more like a || a = b than a = a || b. When a is already truthy, a ||= b performs no assignment at all: the right side is not evaluated, and for an attribute or an index the writer is not called, so a frozen object or a read-only attribute raises nothing. a = a || b always assigns, which matters for setters with side effects and for frozen receivers.

# frozen_string_literal: true

hits = Hash.new(0)
hits[:dune] += 1
p hits

calls = []
store = Hash.new do |hash, key|
  calls << key
  hash[key] = []
end
store[:a] ||= [:x]
p calls, store

# A reader/writer pair that reports every call.
class Counter
  def initialize = @n = nil

  def n
    puts "read"
    @n
  end

  def n=(value)
    puts "write #{value}"
    @n = value
  end
end

c = Counter.new
c.n ||= 5
c.n ||= 7
c.n += 1

Output:

{dune: 1}
[:a]
{a: []}
read
write 5
read
read
write 6

The Counter trace shows the expansion on a writer: the first c.n ||= 5 reads (read), finds nil, then writes (write 5); the second c.n ||= 7 reads, finds 5, and does not write; c.n = 1` reads and then writes `6`. The `Hash.new(0)` line uses the default value so `hits[:dune] += 1` reads `0`, adds and stores `1`. The `store[:a] ||= [:x]` line is a classic trap: the default block assigns `[]` (truthy) while *reading* the missing key, so `||=` finds a truthy value and never stores `[:x]`. Prefer `+store.fetch(:a) { ... } or a plain Hash.new { |h, k| h[k] = [] } with <<.

Memoization and its limits

@value ||= compute is the standard memoization idiom, and it works only while the cached value is truthy. A result of nil or false is recomputed on every call. When nil or false is a legitimate answer, test for the instance variable with defined?:

# frozen_string_literal: true

# Memoization with ||= recomputes when the cached value is nil or false.
class Stats
  attr_reader :calls

  def initialize = @calls = 0

  def slow_nil
    @calls += 1
    @slow_nil ||= nil
  end

  def cached
    return @cached if defined?(@cached)

    @calls += 1
    @cached = nil
  end
end

s = Stats.new
2.times { s.slow_nil }
2.times { s.cached }
p s.calls

# Safe navigation in an abbreviated assignment skips everything when the receiver is nil.
Person = Struct.new(:nickname)
person = Person.new(nil)
person&.nickname ||= "reader"
p person.nickname

nobody = nil
nobody&.nickname ||= "reader"
p nobody

# and/or bind more loosely than =.
off = false
flag = off || true
other = (off or true)
low = off or true
p [flag, other, low]

Output:

3
"reader"
nil
[true, true, false]

Source: Precedence

s.calls is 3: slow_nil ran its body on both calls (a cached nil counts as "not set"), while cached ran it once (the second call returned early because defined?(@cached) was true). The rest of the example shows two more details:

  • person&.nickname ||= "reader" uses safe navigation in an abbreviated assignment. When person is nil the whole expression is skipped (no NoMethodError, no assignment, and nobody stays nil); otherwise it behaves like person.nickname ||= "reader".

  • = binds tighter than and/or/not. flag = off || true stores true, but low = off or true is (low = off) or true and stores false. This is why style guides avoid and/or for logic and reserve them for control flow (do_work or raise "failed"). See Operators and Precedence.

Memoizing in a threaded program is a separate topic: @x ||= compute is not atomic, so two threads can both compute. The Singleton module and Mutex are covered in Threads and the GVL. Class-level memoization of a singleton with @instance ||= new (a pattern shown in older books) is not thread-safe either; use Singleton or a constant created at load time.

Multiple assignment

With more than one target on the left, Ruby performs multiple assignment (also called parallel assignment or destructuring). The right side is evaluated into a list of values, then the targets receive them in order. A comma-separated list on the right of a single target builds an Array implicitly (list = 1, 2, 3), and * on the right expands an array in place; the example covers both.

# frozen_string_literal: true

# rubocop:disable Style/ParallelAssignment
title, author = "Dune", "Herbert"
p [title, author]
# rubocop:enable Style/ParallelAssignment

first, = %w[a b c]
p first

a, b = 1
p [a, b]

x, y = [10, 20, 30]
p [x, y]

head, *tail = [1, 2, 3, 4]
p head, tail

*init, last = [1, 2, 3, 4]
p init, last

_, *mid, _ = 1, 2, 3, 4, 5
p mid

_, *rest = [9]
p rest

list = 1, 2, 3
p list

*wrapped = nil
p wrapped

word = "x"
m, n = *word
p [m, n]

value = (q, r = 4, 5) # rubocop:disable Style/ParallelAssignment
p value
p [q, r]

Output:

["Dune", "Herbert"]
"a"
[1, nil]
[10, 20]
1
[2, 3, 4]
[1, 2, 3]
4
[2, 3, 4]
[]
[1, 2, 3]
[nil]
["x", nil]
[4, 5]
[4, 5]

The rules, in the order the example demonstrates them:

  1. Values to targets, left to right. title, author = "Dune", "Herbert" assigns one value per target.

  2. A single trailing comma (first, = %w[a b c]) means "take only the first element"; it is the idiom for ignoring the rest without a splat.

  3. Too few values leave the extra targets nil (a, b = 1 gives [1, nil]); too many values are silently ignored (a, b = 1, 2, 3 keeps 1 and 2).

  4. A single right-side value that responds to to_ary (an Array, or any object that implements to_ary) is expanded across the targets (x, y = [10, 20, 30]). Anything else, including a Hash or a Data instance, is one value: a, b = { k: 1 } gives a = { k: 1 } and b = nil (a, b = data behaves the same way for a Data instance). Use to_a, values or deconstruct explicitly when you mean it.

  5. A splat target collects the leftovers into a new array: head, *tail = [1, 2, 3, 4] (empty array when nothing is left, *init, last = ... collects from the front, and first, *mid, last = ... keeps both ends). Only one splat target is allowed per level; _, *mid, _ ignores the ends with underscores. Names starting with _ are exempt from the unused-variable warning and from RuboCop’s Lint/UselessAssignment.

  6. A splat on the right expands an object into the list (m, n = *word); it uses to_a and wraps a non-array value (*nil is [], *"x" is ["x"]).

  7. The value of the whole expression is the right side. value = (q, r = 4, 5) stores [4, 5] in value and assigns q and r. Assigning a list to one target (list = 1, 2, 3) builds an array, and *wrapped = nil gives [nil], which differs from wrapped = *nil (that one gives []).

Any assignable target may appear on the left: local, instance, class (@@) and global variables, constants, attributes (obj.attr), indexed elements (h[:k]) and nested patterns. That means the $g, @i = 3, 4 and h[:a], h[:b] = 1, 2 forms all work; constants can only be assigned where the language allows it (not inside a method body), and constant assignment evaluation order is consistent with attributes since Ruby 3.2.

Nested destructuring

Parentheses on the left open a nested multiple assignment. Each parenthesised group is its own multiple assignment over the matching element, with its own splat allowed. The same grammar is used by block parameters, which is why each_with_index { |(key, value), i| ... } works.

# frozen_string_literal: true

a, (b, *c), d = 1, [2, 3, 4], 5
p [a, b, c, d]

(w, x), (y, z) = [1, 2], [3, 4]
p [w, x, y, z]

key, value = { name: "Dune" }.first
p [key, value]

pairs = [[:a, 1], [:b, 2]]
pairs.each { |(k, v)| puts "#{k}=#{v}" }
pairs.each_with_index { |(k, v), i| puts "#{i}: #{k}=#{v}" }
pairs.each { |k, v| puts "#{k}:#{v}" }

def bounds(values) = values.minmax
low, high = bounds([3, 1, 2])
p [low, high]

Output:

[1, 2, [3, 4], 5]
[1, 2, 3, 4]
[:name, "Dune"]
a=1
b=2
0: a=1
1: b=2
a:1
b:2
[1, 3]

Notes on the example:

  • a, (b, *c), d = 1, [2, 3, 4], 5 puts 2 in b and [3, 4] in c; the splat in the group does not compete with one at the outer level.

  • { name: "Dune" }.first returns the pair [:name, "Dune"], which is why key, value = hash.first works; for a whole hash, iterate with each { |k, v| ... }, where the block receives a pair and destructures it.

  • A block written |k, v| auto-destructures a single array argument (pairs.each { |k, v| ... }); a lambda passed as a block does not, because lambdas are strict about arity. The explicit parentheses form |(k, v), i| is what you need when the pair is one of several block arguments.

  • A method returning several values returns an array and the caller destructures it (low, high = bounds(values)), which is how Ruby avoids Go-style multiple returns. return a, b is return [a, b].

  • Destructuring a Hash or Data with a, b = obj does not work; that needs rightward assignment with a pattern, covered below.

Swapping and evaluation order

Because the whole right side is evaluated before any target is assigned, multiple assignment swaps two values without a temporary variable. It is the cleanest way to express Fibonacci-style steps and in-place swaps in algorithms.

# frozen_string_literal: true

a = 1
b = 2
a, b = b, a
p [a, b]

arr = %w[x y z]
arr[0], arr[2] = arr[2], arr[0]
p arr

i = 0 # rubocop:disable Lint/UselessAssignment
list = [10, 20, 30]
i, list[i] = 1, 99 # rubocop:disable Style/ParallelAssignment
p [i, list]

fib_a = 0
fib_b = 1
5.times { fib_a, fib_b = fib_b, fib_a + fib_b }
p fib_a

Output:

[2, 1]
["z", "y", "x"]
[1, [99, 20, 30]]
5

RuboCop’s Style/ParallelAssignment reports parallel assignments that could be written as separate statements. It stays silent for a real swap (a, b = b, a, arr[0], arr[2] = arr[2], arr[0]) and for Fibonacci-style steps where the right side reads the targets, so the file above needs a rubocop:disable directive only for the third statement (i, list[i] = 1, 99). The rule of thumb: use parallel assignment when the values depend on each other or the right side is one expression returning several values; otherwise write separate lines.

The third statement shows the subtle part: in i, list[i] = 1, 99, the list[i] target uses the old value of i because the left-hand side is evaluated before the assignment happens. Ruby 3.1 made this consistent: the receiver and arguments of each target are evaluated first, left to right, then the right side, then the writers run left to right. Before 3.1 the right side ran first, which changed the result for targets with side effects. The trace below prints each step.

# frozen_string_literal: true

# Logs the order in which targets and values are evaluated.
class Tracer
  attr_reader :log

  def initialize = @log = []

  def target(name)
    @log << "target #{name}"
    self
  end

  def value(name)
    @log << "value #{name}"
    name
  end

  def a=(value)
    @log << "set a=#{value}"
  end

  def b=(value)
    @log << "set b=#{value}"
  end
end

t = Tracer.new
t.target(1).a, t.target(2).b = t.value(:x), t.value(:y) # rubocop:disable Style/ParallelAssignment
puts t.log

Output:

target 1
target 2
value x
value y
set a=x
set b=y

Since Ruby 3.2 the same left-to-right order also applies to constant assignment on explicit objects (obj.attr::CONST = value and multiple assignments to constants).

Attribute and index assignment

Assigning to obj.name calls the method name=; assigning to obj[key] calls []=. They are ordinary methods, so they can do anything, and their return value is discarded: the whole assignment expression still evaluates to the right-hand value.

# frozen_string_literal: true

# Setters record the calls they receive.
class Reader
  attr_reader :log

  def initialize = @log = []

  def one=(value)
    @log << [:one, value]
    :ignored
  end

  def two=(value)
    @log << [:two, value]
  end
end

r = Reader.new
r.one, r.two = 1, 2 # rubocop:disable Style/ParallelAssignment
p r.log

x = (r.one = 10)
p x
p r.send(:one=, 11)

Output:

[[:one, 1], [:two, 2]]
10
:ignored

x = (r.one = 10) stores 10 even though one= returns :ignored, while r.send(:one=, 11) is a plain method call and does return :ignored. Remember the following about setters:

  • A setter is defined with def name=(value); it cannot be written as an endless method (def a=(v) = ... is a syntax error: a setter method cannot be defined in an endless method definition).

  • attr_writer and attr_accessor generate the setters; see Classes and Objects.

  • Setters are usually public, but private setters can only be called with the literal receiver self (this is the one exception to the "private methods cannot have an explicit receiver" rule); see Visibility and Encapsulation.

  • Multiple assignment and abbreviated assignment call the writers too (r.one, r.two = 1, 2; c.n += 1).

  • An immutable Data object has no setters at all; use with to create a changed copy (see Struct, Data and Value Objects).

Index assignment and the Ruby 3.4 change

obj[a, b] = value calls obj.[]=(a, b, value): the value is always the last argument. The next class records every argument it receives, including a splat, and an empty index:

# frozen_string_literal: true

# An index setter that records every argument it receives.
class Recorder
  attr_reader :seen

  def initialize = @seen = []

  def []=(*args)
    @seen << args
  end
end

rec = Recorder.new
rec[1, 2] = :v
rec[] = :w
pair = [3, 4]
rec[*pair] = :s
p rec.seen

Output:

[[1, 2, :v], [:w], [3, 4, :s]]

Ruby 3.4 turned two odd corners into syntax errors: keyword arguments and block arguments inside an index assignment (a[0, kw: 1] = 2 and a[0, &b] = 1). Both were accepted by earlier versions; from 3.4 the parser rejects them, as the real messages below show. The portable fix is to call the method by name.

# frozen_string_literal: true

# Ruby 3.4 rejects keyword and block arguments inside an index assignment.
["rec[1, a: 2] = 3", "rec[1, &blk] = 3"].each do |source|
  RubyVM::InstructionSequence.compile(source)
rescue SyntaxError => e
  puts "#{source.inspect} -> #{e.class}"
  puts e.message.lines.grep(/unexpected|not allowed/).first
end

# The portable spelling is an explicit call.
class Recorder
  def []=(*args, **opts)
    @seen = [args, opts]
  end

  attr_reader :seen
end

rec = Recorder.new
rec.[]=(1, a: 2)
p rec.seen

Output:

"rec[1, a: 2] = 3" -> SyntaxError
    |        ^~~~ unexpected keyword arg given in index assignment; keywords are not allowed in index assignment expressions
"rec[1, &blk] = 3" -> SyntaxError
    |        ^~~~ unexpected block arg given in index assignment; blocks are not allowed in index assignment expressions
[[1], {a: 2}]

(The message lines are indented because Prism prints a source excerpt; the first message mentions keywords are not allowed in index assignment expressions and the second blocks are not allowed in index assignment expressions.) Hash#[]= itself takes exactly one key and one value, so this affects only custom []= methods and code that forwarded keyword arguments into a subscript.

Rightward assignment: =>

expression => pattern (Ruby 3.0, stable since 3.1) matches the value against a pattern and binds the names the pattern introduces. It is "assignment from the right" with destructuring: a simple name pattern is just a rightward assignment (42 => answer), and a hash or array pattern pulls values out of a structure. If the pattern does not match it raises, which makes it the assertion form of pattern matching (case/in is the branching form and expression in pattern is the boolean form).

# frozen_string_literal: true

require "json"

config = { db: { host: "localhost", port: 5432 }, tags: %w[a b] }

config => { db: { host:, port: } }
puts "#{host}:#{port}"

config => { tags: [first_tag, *] }
p first_tag

point = Data.define(:x, :y).new(x: 1, y: 2)
point => { x:, y: }
p [x, y]

begin
  config => { missing: _ }
rescue NoMatchingPatternKeyError => e
  puts "#{e.class}: #{e.message}"
end

begin
  1 => String
rescue NoMatchingPatternError => e
  puts "#{e.class}: #{e.message}"
end

assigned = 1 in Integer
p assigned
matched = (1 in Integer)
p matched
p("x".then { it in Integer })

parsed = JSON.parse('{"name":"Dune","pages":412}', symbolize_names: true)
parsed => { name:, pages: Integer => pages }
p [name, pages]

Output:

localhost:5432
"a"
[1, 2]
NoMatchingPatternKeyError: {db: {host: "localhost", port: 5432}, tags: ["a", "b"]}: key not found: :missing
NoMatchingPatternError: 1: String === 1 does not return true
1
true
false
["Dune", 412]

What the example shows:

  • config => { db: { host:, port: } } binds host and port from nested hashes in one statement. A hash pattern matches a subset: keys not in the pattern are ignored. tags: [first_tag, *] mixes in an array pattern with a rest element.

  • Data instances support both deconstruct_keys and (on Ruby 4.0, checked) deconstruct, so point => { x:, y: } and point => [px, py] both work; Struct has the same pair of hooks. Plain objects are matched only if they define them.

  • A missing key raises NoMatchingPatternKeyError (a subclass of NoMatchingPatternError) with the missing key in the message; any other mismatch raises NoMatchingPatternError (1: String === 1 does not return true).

  • in returns true or false and never raises. Mind precedence: assigned = 1 in Integer parses as (assigned = 1) in Integer, so assigned is 1, not true. Parenthesise: matched = (1 in Integer).

  • parsed => { name:, pages: Integer => pages } validates a type while binding, which makes => a lightweight way to assert the shape of parsed JSON (JSON.parse(..., symbolize_names: true) supplies the symbol keys).

  • Pinning (^variable) and guards are available in case/in; the full grammar is on Pattern Matching.

A shelf example

The shelf scenario puts these pieces together: rightward assignment unpacks a Book and its Author, first, = ... grabs the head of a collection, and values_at/fetch_values destructure a hash by key. Run it with ruby -Ilib from the gem directory.

# frozen_string_literal: true

require "shelf"

isbn = Shelf::Isbn.new(digits: "978-0-306-40615-7")
herbert = Shelf::Author.new(name: "Frank Herbert")
book = Shelf::Book.new(isbn:, title: "Dune", author: herbert)

book => { title:, author: { name: } }
puts "#{title} / #{name}"

store = Shelf::Store::Memory.new.add(book)
only, = store.all
puts only
_, *others = store.all
p others

query = { title: "Dune", limit: 5 }
found, limit = query.values_at(:title, :limit)
p [found, limit]
begin
  query.fetch_values(:title, :offset)
rescue KeyError => e
  puts "#{e.class}: #{e.message}"
end

Output:

Dune / Frank Herbert
Dune by Frank Herbert
[]
["Dune", 5]
KeyError: key not found: :offset

values_at returns nil for missing keys, whereas fetch_values raises KeyError for the first missing one, which is the safer choice when the keys are required. Hash#values_at and Hash#fetch_values plus a multiple assignment replace a chain of h[:a]/h[:b] reads.

Where older code and books are outdated

  • Inspect output. Examples written before Ruby 3.4 print hashes as {:a=>1, :b=>2} (including the Ruby documentation for assignment, whose comments still show the old form). From 3.4 Hash#inspect prints {a: 1, b: 2} and string keys as {"k" => 1}; both outputs are valid, only the display changed.

  • Rightward assignment. expr => pattern appeared in 3.0 as experimental and became stable in 3.1. Code that predates it uses case/in or manual destructuring with fetch.

  • Index assignment with keywords or a block (a[0, kw: 1] = 2) is a syntax error from 3.4.

  • Evaluation order of multiple assignment changed in 3.1, and of constant assignment in 3.2; code that depended on the old order (rare, but it exists in DSLs with side-effecting readers) must be rewritten.

  • _1 versus it. Older code destructures pairs in short blocks with _1/_2; Ruby 3.4 added the it parameter for the one-argument case, but destructuring a pair still wants |key, value| or |(a, b), i|.

  • Data and Struct. Value objects are created with Data.define and updated with with, not by assigning to attributes; Struct still has setters (and keyword_init: true is unnecessary for keyword construction since 3.2).

  • Lazy defaults in class bodies. @instance ||= new is not thread-safe, and ||= memoization cannot cache nil or false; use defined? or Singleton as shown above.

Pitfalls and cops

Pitfall What happens Fix

setter without self

creates a local variable, warning assigned but unused variable with -w

self.name = value

a, b = hash or a, b = data

a gets the object, b is nil

a, b = hash.values_at(:a, :b) or hash => { a:, b: }

x = y = []

both names share one object

separate literals, or Array.new(2) { [] }

h[k] ||= v with a default block that stores

the default stores a truthy value first, so v is never used

h.fetch(k) { h[k] = v } or avoid storing in the default block

memo ||= compute where the result may be nil or false

recomputed every call

defined?(@memo) or a sentinel

ok = flag or other

(ok = flag) or other

ok = flag || other

matched = value in Pattern

stores value, not a boolean

matched = (value in Pattern)

i, a[i] = 1, 2 and similar

the target a[i] uses the old i

assign in separate statements when the order matters

a[0, kw: 1] = 2

syntax error since Ruby 3.4

a.[]=(0, kw: 1)

RuboCop cops that touch this page, all enabled by default: Lint/UselessAssignment, Lint/AssignmentInCondition, Lint/SelfAssignment, Lint/RedundantSplatExpansion, Style/ParallelAssignment, Style/OrAssignment (suggests ||= for x = x || y and for unless x; x = y; end), Style/AndOr and Style/TrailingUnderscoreVariable (flags trailing _ targets such as a, _ = pair). See Style, Linting and Static Analysis.