Methods and Parameters

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); features newer than Ruby 3.0 name the version that introduced them. A method is the unit of behaviour in Ruby: every operation, from 1 + 2 to Catalog#add, is a method call, and defining a method is an ordinary expression that returns a Symbol. This page covers the definition side in full: how def works, the names a method may have, the seven kinds of parameters and the single order in which they must appear, how default values are evaluated, what changed when Ruby 3.0 separated positional from keyword arguments, how the body of a method can rescue and ensure without a begin, how super and the method_added hook behave, and how to design small methods with clear signatures. The calling side (receivers, parentheses, splats at the call site, value omission and ... forwarding) is Calling Methods and Argument Forwarding, blocks, procs and lambdas are Blocks, Procs and Lambdas, and the rules that decide which method runs are in Method Lookup and super. Visibility is covered in Visibility and Encapsulation, the exception classes raised by bad calls in Exceptions and Error Handling, and generating methods at run time in Dynamic Dispatch and Method Definition. Methods that live inside classes are introduced in Classes and Objects. For how other languages declare functions, see Go functions and Rust functions.

Every example on this page was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop 1.92 using the configuration of the running shelf gem (target Ruby 4.0, double-quoted strings). The output shown in # => comments, and in the output blocks, is the real output. The examples reuse the names of the shelf library catalogue used throughout this section (Shelf, Book, Isbn, Loan, Catalog); each file is self-contained so it can be run on its own.

Defining a method

def, implicit return and return values

A method definition is def, a name, an optional parameter list, a body and end. The value of the last expression evaluated in the body is the return value, so an explicit return is only needed to leave early. return with several values (return 0, 0) packs them into an Array, and a bare return returns nil. Because the result is an ordinary Array, callers can unpack it with multiple assignment (euros, rest = fine.split).

The def keyword is itself an expression and evaluates to the method name as a Symbol (since Ruby 2.1). That is what makes private def helper and memoize def expensive work: the modifier receives the Symbol that def returned. Redefining a method is not an error. def simply replaces the previous definition, silently unless warnings are enabled with -w, which is how monkey patching works and why it deserves care (see Refinements and Monkey Patching).

The next file defines a small Fine class in the shelf namespace and exercises everything above, together with the operator methods, the unary minus and the element reference covered in the next sections.

# frozen_string_literal: true

module Shelf
  # A late fee, in cents.
  class Fine
    attr_reader :cents

    def initialize(cents)
      @cents = cents
    end

    # Implicit return: the value of the last expression.
    def zero? = cents.zero?

    # Operator methods are ordinary methods with special names.
    def +(other) = Fine.new(cents + other.cents)
    def -@ = Fine.new(-cents)
    def <=>(other) = cents <=> other.cents

    # Several return values are packed into one array.
    def split
      return 0, 0 if zero?

      [cents / 100, cents % 100]
    end

    def to_s = split.then { |euros, rest| format("%<euros>d.%<rest>02d", euros:, rest:) }

    # An assignment method: `fine.cents = 5` always evaluates to 5.
    def cents=(value)
      @cents = Integer(value)
      :ignored
    end

    # Element reference and assignment take any number of arguments.
    def [](index) = to_s[index]
  end
end

fine = Shelf::Fine.new(250)
fine.to_s # => "2.50"
fine.split # => [2, 50]
euros, cents = fine.split
[euros, cents] # => [2, 50]
(fine + Shelf::Fine.new(75)).to_s # => "3.25"
(-fine).cents # => -250
fine[0] # => "2"
[fine.cents = 300, fine.send(:cents=, 400)] # => [300, :ignored]
fine.cents # => 400
fine.zero? # => false
Shelf::Fine.instance_method(:+).owner # => Shelf::Fine

Endless methods (Ruby 3.0)

A method whose body is a single expression can be written without end as def name(params) = expression. The form was added in Ruby 3.0, and Ruby 3.1 allowed a command call as the body, such as def greet = puts "hi". The rules that trip people up:

  • The parentheses around the parameters are mandatory when there are any: def add_one(value) = value + 1 is valid, def add_one value = value + 1 is a syntax error.

  • A setter cannot be endless. def value=(v) = v is rejected with "a setter method cannot be defined in an endless method definition", because the return value of an assignment method is ignored anyway.

  • Endless is a syntactic choice, not a different kind of method. It can have defaults, keywords, a block parameter and super, and it appears in Method#parameters like any other.

  • private def name = expression parses in Ruby 4.0 (the Ruby 3.1 release notes say it did not at the time), so a visibility modifier and an endless method combine.

  • Keep endless methods to one short expression. Anything that needs a rescue clause, a guard or a second statement belongs in a normal def.

The syntax errors are shown with their real parser messages in Syntax errors and warnings below.

Method names

A method name starts with a letter, an underscore or a character outside ASCII (def こんにちは is legal; programs must be written in an ASCII-compatible encoding such as UTF-8), and may contain letters, digits and underscores. By convention words are separated with underscores (snake_case), and a method name never starts with a capital letter unless it deliberately mimics a constant, as Kernel#Integer and Kernel#Array do.

The last character can be a suffix with a convention attached:

Suffix Convention

?

A predicate: the method answers a question. By convention it returns a truthy or falsy value, not necessarily true or false: 5.nonzero? returns 5 and 0.nonzero? returns nil, so if x.nonzero? works but x.nonzero? == true does not.

!

The "dangerous" twin of a method without the bang, usually one that changes the receiver or raises instead of returning a value. The bang is a warning label rather than a guarantee: "x".strip! returns nil when nothing changed, and Array#sort! returns the receiver. A bang method should only exist next to a safer method of the same name.

=

An assignment method (a setter). fine.cents = 5 calls cents=(5) and the expression evaluates to the assigned value 5, never to the method’s return value. Calling it with send shows the real return value.

# frozen_string_literal: true

# By convention `?` methods answer a question and `!` methods are the more
# dangerous twin of a method without the bang.
" padded ".strip # => "padded"
(+"clean").strip! # => nil
[3, 1, 2].sort! # => [1, 2, 3]
[1, 2, 3].uniq! # => nil
5.nonzero? # => 5
0.nonzero? # => nil
"shelf".start_with?("sh") # => true
%w[a b].index("b") # => 1
{ a: 1 }.key?(:a) # => true
:title.respond_to?(:upcase) # => true

Operator methods

Most operators are methods with special names, so any class can define them. Each binary operator takes exactly one argument, the precedence of an operator cannot be changed, and != and !~ are derived from == and =~ unless defined. The operators that can be defined as methods are:

Operators Meaning

+, -, *, /, %, **

Arithmetic: add, subtract, multiply, divide, modulo, power

==, !=, ===, =~, !~

Equality, case equality (when, rescue, grep), pattern match (not only for regexps)

<=>, <, <=, >, >=

Ordering; <=> powers Comparable

<<, >>, &, |, ^

Shifts and append, bitwise and, or and exclusive or

[], []=

Element reference and assignment, with any number of arguments

+@, -@, ~, !

Unary plus and minus (the @ tells them apart from the binary forms), complement and not. For ~ and ! the @ is optional

&&, ||, and, or, defined? and the assignment operators are not methods and cannot be redefined; a += 1 is expanded to a = a + 1 and so uses +. The example above defines +, -@, <=> and []. Defining an operator with a surprising meaning is possible and almost always a mistake; a + that does not add confuses readers.

Parameters

The seven kinds and their order

Ruby has seven kinds of parameter. They must appear in exactly this order, and the parser rejects anything else with "unexpected parameter order":

Order Kind Syntax Method#parameters tag

1

Required

isbn

:req

2

Optional (with a default)

title = "untitled"

:opt

3

Rest (splat)

*tags

:rest

4

Post-required (after the optional or rest parameters)

edition

:req

5

Keyword, required or optional (in any order between themselves)

format:, lang: "en"

:keyreq, :key

6

Keyword rest, or the **nil marker

**extra, **nil

:keyrest, :nokey

7

Block

&callback

:block

flowchart LR R["1 required: a, b"] --> O["2 optional: c = 1"] --> S["3 rest: *tags"] --> P["4 post-required: z"] --> K["5 keywords: k:, j: 1"] --> KR["6 keyword rest: **opts or **nil"] --> B["7 block: blk"]

The names must be unique (the exception is names that start with an underscore, so def ignore(_, _) is valid), a parameter cannot be a constant or an instance variable, and keyword parameters may use reserved words such as if: or class:; the value is then read with binding.local_variable_get(:if). Only one rest parameter is allowed. Optional parameters must be grouped together: def f(a = 1, b, c = 1) is a syntax error, while def f(a = 1, b = 2, c) is fine and def f(a, b = 2, *rest, z) shows required, optional, rest and post-required together.

The file below defines one method with every kind, passes it a mixture of arguments, and then asks Ruby to describe the signature with Method#parameters and Method#arity:

# frozen_string_literal: true

module Shelf
  # Every kind of parameter, in the only order Ruby accepts.
  def self.register(isbn, title = "untitled", *tags, edition, format:, lang: "en", **extra, &note)
    note&.call(isbn)
    { isbn:, title:, tags:, edition:, format:, lang:, extra: }
  end
end

isbn = "9780132350884"
Shelf.register(isbn, "Clean Code", :ruby, :craft, 1, format: :paper, shelf: "A3")
# => {isbn: "9780132350884", title: "Clean Code", tags: [:ruby, :craft], edition: 1,
#     format: :paper, lang: "en", extra: {shelf: "A3"}}
Shelf.register(isbn, 2, format: :ebook) { |value| value }
# => {isbn: "9780132350884", title: "untitled", tags: [], edition: 2,
#     format: :ebook, lang: "en", extra: {}}
Shelf.method(:register).parameters
# => [[:req, :isbn], [:opt, :title], [:rest, :tags], [:req, :edition], [:keyreq, :format],
#     [:key, :lang], [:keyrest, :extra], [:block, :note]]
Shelf.method(:register).arity # => -4

The Hash that register returns uses the Ruby 3.1 shorthand { isbn:, title: }, which stands for { isbn: isbn, title: title }. The edition parameter is post-required: in the second call the lone 2 is not the title but the edition, because required parameters are served before optional ones.

How the arguments are matched to the parameters is mechanical: required parameters are filled first (from the left for the leading ones, from the right for the post-required ones), optional parameters take what is left from the left, and the rest parameter collects the remainder. If the method has keyword parameters, a trailing Hash that was written as key: value pairs at the call (or passed with **) is bound to them. The block, if any, is never counted among the arguments.

Required, optional and post-required parameters

A parameter with a default, title = "untitled", becomes optional. The default is an arbitrary expression that is evaluated at call time, only when the argument is missing, and from left to right. That has four consequences, shown in the next file: a default can use the parameters to its left (hours = days * 24), it can call methods and read constants, a default such as [] creates a new Array on every call (unlike Python, a mutable default is not shared between calls), and a default cannot refer to a parameter that is bound later, which fails with a NameError at call time.

# frozen_string_literal: true

# Defaults are ordinary expressions evaluated on every call that needs them.
module Shelf
  DEFAULT_DAYS = 14

  def self.due_in(days = DEFAULT_DAYS, hours = days * 24)
    { days:, hours: }
  end

  def self.collect(book, into = [])
    into << book
  end

  # A default may call a method, so it can depend on the receiver's state.
  def self.stamp(at = counter)
    at
  end

  def self.counter
    @counter = (@counter || 0) + 1
  end
end

Shelf.due_in # => {days: 14, hours: 336}
Shelf.due_in(7) # => {days: 7, hours: 168}
Shelf.due_in(7, 1) # => {days: 7, hours: 1}
Shelf.collect("a").equal?(Shelf.collect("b")) # => false
Shelf.collect("a") # => ["a"]
[Shelf.stamp, Shelf.stamp, Shelf.stamp(99), Shelf.stamp] # => [1, 2, 99, 3]

# A default cannot look ahead to a parameter that is bound later.
def broken(first = second, second = 1) = [first, second]

begin
  broken
rescue NameError => e
  e.message # => "undefined local variable or method 'second' for main"
end

If a method has post-required parameters, the optional ones are filled only when there are enough arguments for the required ones, which gives the surprising but consistent results of fill(1, 2) and fill(1, 2, 3) in the next section. Prefer keyword arguments over several optional positional parameters: lend(book, days: 7) explains itself at the call site, lend(book, 7) does not.

Rest parameters and array decomposition

A parameter prefixed with * collects the remaining positional arguments into an Array. It can sit at the start, in the middle (with required parameters on both sides), or at the end. A bare * accepts and ignores the arguments, or passes them on unchanged with gather(*) (anonymous rest forwarding, Ruby 3.2). The rest parameter appears before any keyword parameters.

A subtle rule: when the method declares no keyword parameters, keywords written at the call are collected as a trailing Hash by the rest parameter (gather(1, a: 2) gives [1, {a: 2}]). When it does declare keywords, an unknown key raises ArgumentError instead. This is the behaviour that Ruby 3.0 stabilised and is the subject of the section on the 3.0 separation below.

A parameter written in parentheses decomposes one argument with to_ary, in the same way as multiple assignment: the argument is split into the names inside the parentheses, extra elements are dropped, and a non-Array argument goes to the first name while the others become nil. A * inside the parentheses collects the rest. The decomposed parameter counts as one positional parameter and appears as [:req] without a name in Method#parameters. Define to_ary on a class only if it can truly be used in place of an Array.

# frozen_string_literal: true

# Splats, post-required parameters and array decomposition.
def slice_of(first, *middle, last) = { first:, middle:, last: }

slice_of(1, 2) # => {first: 1, middle: [], last: 2}
slice_of(1, 2, 3, 4) # => {first: 1, middle: [2, 3], last: 4}

# Optional parameters are filled before the splat, after the required ones.
def fill(a, b = :b, *rest, z) = [a, b, rest, z]

fill(1, 2) # => [1, :b, [], 2]
fill(1, 2, 3) # => [1, 2, [], 3]
fill(1, 2, 3, 4, 5) # => [1, 2, [3, 4], 5]

# Parentheses decompose one argument with to_ary, like multiple assignment.
def title_and_author((title, author), year) = "#{title} (#{author}, #{year})"

title_and_author(%w[Dune Herbert], 1965) # => "Dune (Herbert, 1965)"
title_and_author("Dune", 1965) # => "Dune (, 1965)"

def head_and_tail((head, *tail)) = [head, tail]

head_and_tail([1, 2, 3]) # => [1, [2, 3]]

# Without keyword parameters a trailing keyword list becomes a Hash.
def gather(*args) = args

gather(1, a: 2) # => [1, {a: 2}]

def gather_with_keyword(*args, only: nil) = [args, only]

begin
  gather_with_keyword(1, other: 3)
rescue ArgumentError => e
  e.message # => "unknown keyword: :other"
end

# Bare `*` accepts and ignores the arguments, or passes them on.
def ignore(*) = :ignored

def forward_positional(*) = gather(*)

ignore(1, 2, 3) # => :ignored
forward_positional(1, 2, 3) # => [1, 2, 3]

Keyword parameters

A keyword parameter is declared as name: (required) or name: default (optional). Callers pass the keywords in any order, and a call that omits a required keyword or passes an unknown one raises an ArgumentError that names the keywords (missing keyword: :member, unknown keyword: :color). Required and optional keywords may be declared in any order relative to each other. A default can use the keywords and optional parameters to its left, evaluated at call time as for positional defaults.

**opts collects every keyword that has no parameter of its own into a Hash (with Symbol keys, and since Ruby 2.7 also with other keys). A bare ** accepts the keywords without naming them, or forwards them with other(**) (Ruby 3.2). **nil goes the other way: it declares that the method accepts no keywords, so that a call with keywords raises ArgumentError (no keywords accepted) instead of silently turning them into a positional Hash. It is used to reserve the right to add keywords later without breaking callers, and it cannot be combined with other keyword parameters. On the calling side, **nil is allowed from Ruby 3.4 and passes no keywords.

When the caller already has local variables named like the keywords, Ruby 3.1 lets the call omit the value: Shelf.lend(book:, member:, days: 7) is short for book: book, member: member. That is the calling side and is covered in Calling Methods and Argument Forwarding.

# frozen_string_literal: true

# Keyword parameters: required, optional, **rest and **nil.
module Shelf
  def self.search(query, limit: 10, genre: nil, **filters)
    { query:, limit:, genre:, filters: }
  end

  def self.lend(book:, member:, days: 14) = "#{book} to #{member} for #{days} days"

  def self.no_options(value, **nil) = value
end

Shelf.search("ruby") # => {query: "ruby", limit: 10, genre: nil, filters: {}}
Shelf.search("ruby", genre: :tech, limit: 5, year: 2025)
# => {query: "ruby", limit: 5, genre: :tech, filters: {year: 2025}}
Shelf.search("ruby", **{ "lang" => "en" })
# => {query: "ruby", limit: 10, genre: nil, filters: {"lang" => "en"}}
Shelf.lend(member: "ana", book: "Dune") # => "Dune to ana for 14 days"

book = "Dune"
member = "ana"
Shelf.lend(book:, member:, days: 7) # => "Dune to ana for 7 days"

calls = [
  -> { Shelf.lend(book: "Dune") },
  -> { Shelf.lend },
  -> { Shelf.lend(book: "Dune", member: "ana", color: :red) },
  -> { Shelf.lend("Dune", "ana") },
  -> { Shelf.no_options(1, verbose: true) }
]
messages = calls.map do |call|
  call.call
rescue ArgumentError => e
  e.message
end
pp messages

A method without keyword parameters and without **nil that receives key: value arguments gets them as a final positional Hash (meth(a: 1) binds {a: 1} to arg). That is still true in Ruby 4.0, which is why the 3.0 rules below are worth learning precisely. The same call with an explicit brace Hash, meth({a: 1}), is one positional argument in every version.

The block parameter

The last parameter may be &name; it receives the block of the call as a Proc (or nil if there is none). It is mostly used to pass the block on to another method. Ruby 3.1 added the anonymous form def each(&) = @items.each(&) for the case where the block is only forwarded. When the method just calls the block, prefer yield with block_given?, which does not allocate a Proc, and enum_for(__method__) to return an Enumerator when no block is given (the pattern used by the shelf Catalog#each).

# frozen_string_literal: true

# Blocks are an implicit last argument: yield, block_given? and &block.
module Shelf
  def self.each_title(titles)
    return enum_for(__method__, titles) unless block_given?

    titles.each { |title| yield title.upcase, title.size }
  end

  def self.capture(&block) = block

  def self.pass_along(titles, &) = titles.map(&)
end

Shelf.each_title(%w[Dune Emma]).to_a # => [["DUNE", 4], ["EMMA", 4]]
Shelf.each_title(%w[Dune Emma]) { |title, size| puts "#{title}: #{size}" }
captured = Shelf.capture { |a, b| [a, b] }
[captured.class, captured.arity, captured.lambda?] # => [Proc, 2, false]
Shelf.pass_along(%w[Dune Emma], &:size) # => [4, 4]
Shelf.capture # => nil

Run, the method prints the two upper-cased titles with their length:

DUNE: 4
EMMA: 4

A block is created by the caller; the Proc that &block returns is a regular (non-lambda) proc with its own arity, which is why captured.arity above is 2 for a block with two parameters. The full semantics of yield, return inside blocks, Method#to_proc and Symbol#to_proc (the &:size argument) are in Blocks, Procs and Lambdas.

Anonymous parameters and forwarding

Three shorthand forms avoid naming a parameter whose only job is to be passed on. Ruby 2.7 added ... (all arguments and the block, with leading arguments allowed from 3.0), Ruby 3.1 the anonymous block &, and Ruby 3.2 the anonymous * and **. A method can use *, ** and & in the same parameter list. ... stands for all three, so it cannot be combined with a rest parameter or a block (def f(*, ...) is rejected with "…​ after rest argument"), and ... is only valid in a method that declares it (def f(*) = g(...) is a syntax error, shown in the diagnostics below). Method#parameters reports all of them under the placeholder names :*, :** and :&:

# frozen_string_literal: true

# Method#parameters reports each parameter kind, including anonymous ones.
def positional(a, b = 1, *c, d) = [a, b, c, d]
def keywords(e:, f: 1, **g) = [e, f, g]
def anonymous(*, **, &) = nil
def forwarding(...) = nil
def no_keywords(**nil) = nil
def decomposed((a, b), c) = [a, b, c]

parameters = %i[positional keywords anonymous forwarding no_keywords decomposed].to_h do |name|
  [name, method(name).parameters]
end
pp parameters

Output:

{positional: [[:req, :a], [:opt, :b], [:rest, :c], [:req, :d]],
 keywords: [[:keyreq, :e], [:key, :f], [:keyrest, :g]],
 anonymous: [[:rest, :*], [:keyrest, :**], [:block, :&]],
 forwarding: [[:rest, :*], [:keyrest, :**], [:block, :&]],
 no_keywords: [[:nokey]],
 decomposed: [[:req], [:req, :c]]}

Ruby 4.0 changed one forwarding detail: *nil at a call site no longer calls nil.to_a, in the same way that **nil never called nil.to_hash.

Method#parameters and arity

Method#parameters (and UnboundMethod#parameters, Proc#parameters) returns an Array of [kind, name] pairs. The kinds are :req, :opt, :rest, :keyreq, :key, :keyrest, :block and :nokey. A non-lambda proc reports its required parameters as :opt, and Proc#parameters(lambda: true) (Ruby 3.2) asks for the lambda view. Frameworks use this to adapt a callback to the number of arguments it accepts.

Method#arity is a single number summarising the same information:

  • n (zero or more) when the method takes exactly n positional arguments and no optional ones;

  • -n-1 when it takes n required arguments plus optional or rest arguments (-1 for def f(*rest), -2 for def f(a, b = 1), -3 for def f(a, *rest, z));

  • keywords count as one extra argument, which is mandatory if any keyword is required: def f(a, k:) has arity 2, def f(k:) has 1, def f(k: 1) has -1, def f(a, k: 1) has -2;

  • a block parameter never counts, and ... has arity -1.

For a proc (not a lambda), optional arguments do not make the arity negative, so proc { |a, b = 1| }.arity is 1 while lambda { |a, b = 1| }.arity is -2.

# frozen_string_literal: true

# Method#arity for the common shapes.
module Arities
  def none; end
  def one(a) = a
  def two(a, b) = [a, b]
  def optional(a, b = 1) = [a, b]
  def splat(*rest) = rest
  def post(a, *rest, z) = [a, rest, z]
  def required_keyword(a:) = a
  def optional_keyword(a: 1) = a
  def mixed(a, b:) = [a, b]
  def mixed_optional(a, b: 1) = [a, b]
  def keyrest(**opts) = opts
  def forwarding(...) = nil
  def block_only(&block) = block
end

arities = Arities.instance_methods(false).sort.to_h { [it, Arities.instance_method(it).arity] }
pp arities

Output:

{block_only: 0,
 forwarding: -1,
 keyrest: -1,
 mixed: 2,
 mixed_optional: -2,
 none: 0,
 one: 1,
 optional: -2,
 optional_keyword: -1,
 post: -3,
 required_keyword: 1,
 splat: -1,
 two: 2}

Source: Method#arity

What ArgumentError looks like

Wrong calls are reported as an ArgumentError whose message states what was given and what was expected. The backtrace points to the method definition line first (the Class#initialize frame for new), then to the call:

Call Message

def f(a, *r) called with no arguments

wrong number of arguments (given 0, expected 1+)

def f(a, b = 1) called with three

wrong number of arguments (given 3, expected 1..2)

def f(a, b) called with one

wrong number of arguments (given 1, expected 2)

def lend(book:, member:) called with a missing keyword

missing keyword: :member (several: missing keywords: :book, :member)

def f(a: 1) called with b: 1, c: 2

unknown keywords: :b, :c

def f(a: 1) called with f(1)

wrong number of arguments (given 1, expected 0)

def f(a, k:) called with f(1, 2)

wrong number of arguments (given 2, expected 1; required keyword: k)

def f(**nil) called with f(a: 1)

no keywords accepted

An undefined method or variable raises NoMethodError or NameError with the 3.4 message format, which names the receiver by its class: undefined method 'foo' for nil, undefined method 'zork' for an instance of Object, undefined local variable or method 'second' for main, private method 'helper' called for an instance of String. Messages from before Ruby 3.4 quoted the method with a backtick and ended with for nil:NilClass; code that matches on the text of these messages needs updating.

Positional and keyword arguments since Ruby 3.0

Until Ruby 2.6 a method that accepted keywords also accepted a final Hash and converted between the two, which caused ambiguous calls that were fixed in surprising ways. Ruby 2.7 printed warnings for every place where the conversion happened and Ruby 3.0 removed it: keyword arguments are now strictly separate from positional arguments. The rules are short:

Situation Ruby 3.0 and later

A method with keyword parameters is called with a Hash

The Hash is one positional argument. with_keywords(attrs) raises ArgumentError; use with_keywords(**attrs) to pass its pairs as keywords.

A method without keyword parameters is called with key: value

The pairs become one trailing positional Hash. with_hash(title: "Dune") binds {title: "Dune"}.

A method accepts an optional Hash and **keywords

Braces make a positional Hash (with_both({title: "Dune"})), bare pairs make keywords (with_both(title: "Dune")).

**{} or **empty_hash

Passes no arguments at all (it does not pass an empty Hash), so with_hash(**{}) raises "given 0, expected 1".

A rest parameter delegates with *args

The final keyword Hash is just a Hash unless the method is marked with ruby2_keywords. Use (*args, **options, &block) or (...) instead.

A non-lambda proc whose only parameters are one rest parameter and keywords

No longer autosplats an Array argument (3.0); a proc with a single positional parameter and keywords no longer autosplats either (3.2).

The next file shows these rules at work, with the real error messages of the calls that no longer work:

# frozen_string_literal: true

# Since Ruby 3.0 positional and keyword arguments are separate.
def with_keywords(title:, year: nil) = [title, year]
def with_hash(options) = options
def with_both(options = {}, **keywords) = [options, keywords]
def with_rest(*args) = args

attrs = { title: "Dune", year: 1965 }
splat = [attrs]

with_keywords(**attrs) # => ["Dune", 1965]
with_hash(title: "Dune") # => {title: "Dune"}
with_hash(**attrs) # => {title: "Dune", year: 1965}
with_both({ title: "Dune" }) # => [{title: "Dune"}, {}]
with_both(title: "Dune") # => [{}, {title: "Dune"}]
with_rest(1, title: "Dune") # => [1, {title: "Dune"}]

calls = {
  hash_to_keywords: -> { with_keywords(attrs) },
  hash_and_keywords: -> { with_keywords({ title: "Dune" }, year: 1965) },
  splatted_hash: -> { with_keywords(*splat) },
  empty_to_required: -> { with_hash(**{}) }
}
errors = calls.transform_values do |call|
  call.call
rescue ArgumentError => e
  e.message
end
pp errors

Output:

{hash_to_keywords:
  "wrong number of arguments (given 1, expected 0; required keyword: title)",
 hash_and_keywords:
  "wrong number of arguments (given 1, expected 0; required keyword: title)",
 splatted_hash:
  "wrong number of arguments (given 1, expected 0; required keyword: title)",
 empty_to_required: "wrong number of arguments (given 0, expected 1)"}

Source: Ruby 3.0.0 NEWS

The 2.7 deprecation warnings are gone, so there is no way to opt back in; the only compatibility switch left is ruby2_keywords for delegators, described next. The reasoning for the change is given in the official article Separation of positional and keyword arguments in Ruby 3.0.

Delegating without losing the keyword distinction

A method that gathers *args and calls target.method(*args) used to forward keywords transparently, because the keywords were just a trailing Hash that Ruby turned back into keywords. After Ruby 3.0 the Hash stays positional and the call fails (the Broken class below). There are three fixes, in order of preference:

  1. declare the keywords explicitly, def lend(*args, **options, &block) (or its anonymous form def lend(*, **, &), Ruby 3.2);

  2. use ..., which forwards everything, including a leading argument you inspect: def lend(book, ...) (Ruby 3.0);

  3. mark the method with ruby2_keywords, which flags the trailing Hash so that a later *args splat turns it back into keywords. This is the bridge that libraries use to support Ruby 2.6 and 3.x with one method, and it also exists as Proc#ruby2_keywords.

# frozen_string_literal: true

# Delegation without losing the keyword/positional distinction.
class Recorder
  attr_reader :log

  def initialize = @log = []

  def lend(book, member:, days: 14)
    @log << [book, member, days]
  end
end

# Modern forms: anonymous forwarding with its own default, or `...` with a leading argument.
class Explicit
  def initialize(target) = @target = target

  def lend(book, *, days: 7, **, &) = @target.lend(book, *, days:, **, &)
end

class Shorthand
  def initialize(target) = @target = target

  def lend(book, ...) = @target.lend(book.strip, ...)
end

# ruby2_keywords is the Ruby 2.7 bridge for code that only gathers *args.
class Legacy
  def initialize(target) = @target = target

  ruby2_keywords def lend(*, &)
    @target.lend(*, &)
  end
end

logs = [Explicit, Shorthand, Legacy].map do |klass|
  recorder = Recorder.new
  klass.new(recorder).lend("Dune", member: "ana")
  recorder.log.first
end
pp logs

# Without ruby2_keywords the keywords arrive as a last positional Hash.
class Broken
  def initialize(target) = @target = target

  def lend(*, &) = @target.lend(*, &)
end

begin
  Broken.new(Recorder.new).lend("Dune", member: "ana")
rescue ArgumentError => e
  e.message # => "wrong number of arguments (given 2, expected 1; required keyword: member)"
end

Explicit forwards anonymously and adds its own default days:, Shorthand inspects the leading argument and forwards the rest with ..., Legacy relies on ruby2_keywords, and Broken shows the error message of the delegator that does neither. Run, the three working delegators record the call, and the last one fails with the message in the comment of the file:

Output:

[["Dune", "ana", 7], ["Dune", "ana", 14], ["Dune", "ana", 14]]

ruby2_keywords is a transitional tool. It is deprecated in the development version that will become Ruby 4.1 (the development NEWS lists Module#ruby2_keywords, top-level ruby2_keywords and Proc#ruby2_keywords as deprecated, with removal planned for Ruby 4.4), so new code should use ... or explicit forwarding, and this page does not rely on the deprecation in 4.0. A hash that carries the flag can be recognised with Hash.ruby2_keywords_hash?.

A last property worth knowing: a **opts parameter is always a fresh Hash, so mutating it never affects the caller’s Hash, while a Hash passed positionally is the same object:

# frozen_string_literal: true

# **opts is a fresh Hash on every call: the caller's Hash is never changed.
def tweak(**opts)
  opts[:seen] = true
  opts
end

original = { title: "Dune" }
result = tweak(**original)
[original, result, result.equal?(original)] # => [{title: "Dune"}, {title: "Dune", seen: true}, false]

# A Hash passed as a positional argument IS shared, so mutating it leaks out.
def tweak_positional(options)
  options[:seen] = true
  options
end

shared = { title: "Dune" }
tweak_positional(shared).equal?(shared) # => true
shared.to_h # => {title: "Dune", seen: true}

Method bodies

rescue, else, ensure and retry without begin

A method body is an implicit begin block. rescue, else and ensure can follow the body directly, and retry inside a rescue clause runs the body again. ensure runs once when the method finally leaves, however it leaves, including after several retries; its own value is discarded unless it executes return, in which case that return value wins and an in-flight exception is silently swallowed (a classic bug, flagged by RuboCop’s Lint/EnsureReturn). For a rescue of only part of the body, use an explicit begin/end. Inside a rescue clause Ruby sets cause automatically to the exception being handled; the example passes cause: e explicitly to document the intent, and it is required when raising outside a rescue clause.

# frozen_string_literal: true

module Shelf
  class Error < StandardError; end
  class InvalidIsbn < Error; end

  # A method body is an implicit begin block: rescue, else, ensure
  # and retry work without an explicit `begin`.
  class Importer
    attr_reader :trail

    def initialize(failures: 0)
      @failures = failures
      @attempts = 0
      @trail = []
    end

    def import(raw)
      @attempts += 1
      @trail << :try
      raise InvalidIsbn, "flaky source" if @attempts <= @failures

      Integer(raw)
    rescue InvalidIsbn
      @trail << :retry
      retry if @attempts < 3
      raise
    rescue ArgumentError, TypeError => e
      @trail << :bad_input
      raise InvalidIsbn, "not a number: #{raw.inspect}", cause: e
    else
      @trail << :ok
      :imported
    ensure
      @trail << :done
    end
  end
end

importer = Shelf::Importer.new(failures: 2)
importer.import("9780132350884") # => :imported
importer.trail # => [:try, :retry, :try, :retry, :try, :ok, :done]

begin
  Shelf::Importer.new(failures: 5).import("9780132350884")
rescue Shelf::InvalidIsbn => e
  e.message # => "flaky source"
end

begin
  Shelf::Importer.new.import("x1")
rescue Shelf::InvalidIsbn => e
  [e.message, e.cause.class] # => ["not a number: \"x1\"", ArgumentError]
end

# The value of an `ensure` clause is discarded, unless it executes `return`.
def ensure_value(log)
  :body
ensure
  log << :cleanup
end

cleanup_log = []
ensure_value(cleanup_log) # => :body
cleanup_log.to_a # => [:cleanup]

The second block in the file shows the discarded ensure value: ensure_value returns :body, and the ensure clause only ran its side effect on the log. The exception hierarchy and the rules for what to rescue are in Exceptions and Error Handling.

return in methods, procs and lambdas

return leaves the method, even from inside a block: first_even returns from itself while each is iterating. Inside a lambda, return leaves only the lambda. A proc (or block) created in a method that has already returned cannot return from it, and raises LocalJumpError (unexpected return). The same rules for break and next are drawn as a diagram in Blocks, Procs and Lambdas. A bare return returns nil, and return a, b returns [a, b].

# frozen_string_literal: true

# return, break and next differ between a method, a proc and a lambda.
def first_even(numbers)
  numbers.each { |n| return n if n.even? }
  nil
end

first_even([1, 3, 4, 5]) # => 4

def lambda_return
  early = lambda do |value|
    return :from_lambda if value

    :not_reached
  end
  [early.call(true), :method_continues]
end

lambda_return # => [:from_lambda, :method_continues]

def orphan = proc { return :from_method }

begin
  orphan.call
rescue LocalJumpError => e
  [e.class, e.message] # => [LocalJumpError, "unexpected return"]
end

# Several return values form an Array; a bare `return` returns nil.
def bounds(list)
  return if list.nil?
  return 0, 0 if list.empty?

  list.minmax
end

bounds([3, 1, 2]) # => [1, 3]
bounds([]) # => [0, 0]
bounds(nil) # => nil
low, high = bounds([3, 1, 2])
[low, high] # => [1, 3]

The assignment-method rule from the first example applies here too: the value a setter returns is ignored when it is called with the assignment syntax, so return in a setter is harmless but pointless.

__method__, __callee__, aliases and the method_added hook

Inside a method, __method__ returns the Symbol of the method being defined and __callee__ the name it was called by; the two differ for an alias. They are what enum_for(__method__) uses. caller_locations(1, 1).first.label names the calling method.

When a method is defined, Module#method_added is called on the class (and singleton_method_added on the object for a singleton method); method_removed and method_undefined mirror it for remove_method and undef_method. Every way of creating a method triggers it: def, define_method, attr_accessor, alias. The hook must call super, otherwise any other module in the chain that defines it is silenced. alias (a keyword, the old and new names are not Symbols) and Module#alias_method (a method, usable in class_eval and with computed names) copy a method under a second name; Method#original_name keeps the first. The example prints what the hook observed:

# frozen_string_literal: true

# `def` is an expression that returns the method name as a Symbol.
# The Module hooks tell a class which methods are being added and removed.
class Audited
  @added = []
  @removed = []

  class << self
    attr_reader :added, :removed

    def method_added(name)
      @added << name
      super
    end

    def method_removed(name)
      @removed << name
      super
    end
  end

  RETURNED = [def checkout = :out, private(def secret = :hidden)].freeze

  attr_accessor :note

  define_method(:dynamic) { :made }
  alias borrow checkout
  remove_method :checkout

  def who_am_i = [__method__, __callee__]
  alias alias_of_who who_am_i
end

Audited::RETURNED.to_a # => [:checkout, :secret]
Audited.added # => [:checkout, :secret, :note, :note=, :dynamic, :borrow, :who_am_i, :alias_of_who]
Audited.removed # => [:checkout]
Audited.new.who_am_i # => [:who_am_i, :who_am_i]
Audited.new.alias_of_who # => [:who_am_i, :alias_of_who]
Audited.instance_method(:borrow).name # => :borrow
Audited.instance_method(:borrow).original_name # => :checkout
Audited.private_instance_methods(false) # => [:secret]

Two lines are worth reading closely. Audited::RETURNED is the pair of Symbols that the two def expressions returned (the private(...) call returns its argument since Ruby 3.1). And alias_of_who reports [:who_am_i, :alias_of_who]: __method__ is the original name, __callee__ the alias. Visibility is covered in Visibility and Encapsulation.

The forms of super

Inside a method that overrides an inherited one, super has three forms, and the difference is which arguments they pass:

  • super (implicit, "zsuper") passes the current values of the method’s own parameters, including keywords and the block. If the method reassigned a parameter before calling super, the new value is passed.

  • super() passes no arguments (but still passes the block).

  • super(a, k: 1) passes exactly what is written, and super(...) passes the forwarded arguments of a ... method.

# frozen_string_literal: true

# The three forms of super, seen through the parameters they pass.
class Base
  def lend(book, member:, days: 14) = [book, member, days]
end

class Implicit < Base
  def lend(book, member:, days: 14)
    book = book.upcase
    super # passes the current values of book, member and days
  end
end

class Explicit < Base
  def lend(book, member:, days: 14)
    super(book, member: member.capitalize, days: days * 2)
  end
end

class Bare < Base
  def lend(book, member:, days: 14)
    super()
  ensure
    [book, member, days]
  end
end

class Anonymous < Base
  def lend(book, ...)
    super(book.upcase, ...)
  end
end

Implicit.new.lend("dune", member: "ana") # => ["DUNE", "ana", 14]
Explicit.new.lend("dune", member: "ana") # => ["dune", "Ana", 28]
Anonymous.new.lend("dune", member: "ana", days: 3) # => ["DUNE", "ana", 3]

begin
  Bare.new.lend("dune", member: "ana")
rescue ArgumentError => e
  e.message # => "wrong number of arguments (given 0, expected 1; required keyword: member)"
end

Implicit super is not allowed inside define_method, and in a method with anonymous parameters it forwards them. How the next method is found is explained in Method Lookup and super.

Top-level methods and overriding

A def outside any class defines a private method on Object, so it is callable from everywhere, including inside other classes, but not with an explicit receiver. That is convenient in scripts and a source of namespace pollution in libraries; put library code in a module or class. send bypasses the visibility check.

Redefinition replaces the old method. With ruby -w (or $VERBOSE = true) Ruby warns "method redefined; discarding old greet" and points to the previous definition, which is useful in tests and CI. The next file redefines Greeter#greet from a string so that the warning is not a RuboCop offence; run with -w, it prints the two warning lines shown.

# frozen_string_literal: true

# A `def` at the top level defines a private method on Object.
def helper = :helper

Object.private_method_defined?(:helper) # => true
Object.public_method_defined?(:helper) # => false
self.class # => Object

begin
  "text".helper
rescue NoMethodError => e
  e.message # => "private method 'helper' called for an instance of String"
end

"text".send(:helper) # => :helper

# Redefining a method is silent unless warnings are enabled (ruby -w).
class Greeter
  def greet = "hello"
end

Greeter.class_eval <<~RUBY, __FILE__, __LINE__ + 1
  def greet = "hello again"
RUBY

Greeter.new.greet # => "hello again"
$ ruby -w e14.rb
e14.rb:24: warning: method redefined; discarding old greet
e14.rb:20: warning: previous definition of greet was here

Redefining a core method such as String#to_i affects every caller in the process, the case the Ruby documentation itself warns about. Prefer a refinement, a subclass, prepend or a new method name (see Refinements and Monkey Patching).

Designing methods and signatures

The mechanics above leave a lot of freedom. These guidelines keep the freedom from turning into confusion:

  • Small methods with one job. A method that needs a comment to explain a section usually contains a method trying to get out. Ruby makes extracting cheap: no types to declare, and a block can often replace a helper.

  • Few positional parameters. One to three. Beyond that, use keyword parameters (lend(book:, member:, days: 14)) or a parameter object.

  • Parameter objects. Related arguments that always travel together become a value object. Data.define is the right tool (Ruby 3.2): immutable, with positional or keyword construction, with, to_h and pattern matching. A custom initialize with keyword defaults and validation keeps the invariant in one place (see Struct, Data and Value Objects).

  • Guard clauses first. Reject bad input at the top with raise ArgumentError and return early for the trivial case, so the rest of the method is the happy path, unindented. Check types sparingly: Ruby is duck typed, so respond_to? or an is_a? check belongs at the boundary of the library, not in every method.

  • Normalise with conversion methods. Kernel#Array turns nil into [], wraps a scalar and leaves an Array alone; Integer("42"), String(x) and Hash(x) are the strict conversions covered in Conversions and Coercion.

  • One kind of return value. A method that returns an Array sometimes, nil sometimes and a String on error forces every caller to check. Return an empty Array for "nothing", raise for failure, and use nil only when absence is a valid answer.

  • Name predicates and queries by their answer (loaned?, overdue?), commands by their action (lend, return_book), and keep a setter free of surprises. Override to_s for the human form and inspect for debugging.

# frozen_string_literal: true

module Shelf
  # A parameter object: related arguments travel together, with defaults and
  # validation in one place.
  SearchQuery = Data.define(:text, :limit, :genres) do
    def initialize(text:, limit: 10, genres: [])
      raise ArgumentError, "text must be a String, got #{text.class}" unless text.is_a?(String)
      raise ArgumentError, "limit must be positive" unless limit.positive?

      super(text: text.strip, limit:, genres: Array(genres).freeze)
    end

    def to_s = "#{text} (#{genres.join(', ')})"
  end

  # Guard clauses reject bad input first; the happy path stays unindented.
  def self.search(titles, query)
    return [] if titles.empty?

    pattern = query.text.downcase
    titles.select { it.downcase.include?(pattern) }.first(query.limit)
  end
end

titles = ["Programming Ruby", "The Ruby Way", "Eloquent Ruby", "Dune"]
query = Shelf::SearchQuery.new(text: " ruby ", limit: 2, genres: :tech)
query.to_h # => {text: "ruby", limit: 2, genres: [:tech]}
Shelf.search(titles, query) # => ["Programming Ruby", "The Ruby Way"]
Shelf.search([], query) # => []
Shelf::SearchQuery.new(text: "dune").genres # => []
Array(nil) # => []
Array("one") # => ["one"]
Array(titles).equal?(titles) # => true
Array(1..3) # => [1, 2, 3]
Array({ a: 1 }) # => [[:a, 1]]

begin
  Shelf::SearchQuery.new(text: :ruby)
rescue ArgumentError => e
  e.message # => "text must be a String, got Symbol"
end

Source: Kernel#Array

Pass objects instead of rows of arguments, and let a method with a long signature own a single Data parameter: the call site Shelf.search(titles, query) reads the same whether the query has two fields or twenty.

  • Duck typing makes signatures the interface. Ruby has no declared interfaces: the parameter list and the messages the body sends to each argument are the contract. A method that calls each on its argument accepts an Array, a Catalog, a Set or an Enumerator. Document that expectation (and type it with RBS when it matters) instead of checking classes.

  • Chains and long conditions. Put each step of a method chain on its own line, starting with the dot. Since Ruby 4.0 a line that starts with && or || continues the previous one in the same way, so long conditions need no trailing operator.

  • Comments explain why. A comment that repeats the code goes stale; one that records a decision or a constraint does not. Document the public contract of a method with RDoc or YARD comments (see Documentation with RDoc and YARD).

# frozen_string_literal: true

module Shelf
  Member = Data.define(:name, :active)

  # Since Ruby 4.0 a line that starts with && or || continues the previous one.
  def self.lendable?(copies, member)
    copies.positive?
      && member.active
      && !member.name.empty?
  end

  # Leading dots keep a method chain readable, one step per line.
  def self.clean_titles(raw)
    raw
      .compact
      .map(&:strip)
      .reject(&:empty?)
      .map(&:capitalize)
  end
end

ana = Shelf::Member.new(name: "Ana", active: true)
Shelf.lendable?(2, ana) # => true
Shelf.lendable?(0, ana) # => false
Shelf.lendable?(1, ana.with(active: false)) # => false
Shelf.clean_titles(["  dune ", "", nil, "EMMA"]) # => ["Dune", "Emma"]

Source: Ruby 4.0.0 NEWS

Where older books and tutorials are outdated

Several well-regarded Ruby books date from the Ruby 2.x and early 3.x era, and what they say about methods needs adjusting for Ruby 4.0:

Older statement Current behaviour

Error messages that quote the method name with a backtick and end in for nil:NilClass

Ruby 3.3 changed the receiver text and 3.4 the quotes: undefined method 'foo' for nil. A backtick before the method name is the pre-3.4 form.

A Hash is converted to keywords (and the reverse) when calling

Removed in Ruby 3.0. Use **hash at the call and **opts or ... in delegators; the 2.7 warnings no longer exist.

def create_car(make:, model) and other mixes of keyword and later positional parameters

A syntax error in every version: positional parameters come before keywords. The parser says "unexpected parameter order".

Bare super forwards the original arguments

It forwards the current values of the parameters, including keywords. In a method that changed book, super passes the changed value; use super() to pass nothing.

NotImplementedError as the way to mark an abstract method

NotImplementedError is a ScriptError, which a bare rescue does not catch. Raise NoMethodError or a custom Shelf::Error subclass instead.

! means "changes the receiver" and ? "returns a Boolean"

These are conventions with exceptions: "x".strip! returns nil when nothing changed, 5.nonzero? returns 5. Write predicates to return a truthy or falsy value and bang methods only next to a safer twin.

Only _1 for short blocks

it (Ruby 3.4) is the idiomatic implicit parameter for one-argument blocks; the RuboCop cop Style/ItBlockParameter suggests it.

Methods written with def ... end for every one-liner

Endless methods (3.0), argument forwarding with ... (2.7, 3.0 with leading arguments), anonymous *, ** and & (3.1, 3.2) and the name: value omission (3.1) are standard in current code and in RuboCop’s defaults.

Subclassing the result of Struct.new or Data.define (class Book < Struct.new(:isbn))

Assign the generated class to a constant instead, Book = Data.define(:isbn) do ... end; subclassing creates an extra anonymous class.

Hash#inspect shown as {:name=>"Ico"}

Ruby 3.4 prints {name: "Ico"}; the output on this page uses the 3.4 format.

Common exceptions and warnings

Exception or warning Typical cause

ArgumentError

Wrong arity, missing or unknown keyword, a validation failure raised on purpose. The message always says what was given and expected.

NoMethodError

The receiver does not respond to the method, the method is private or protected and was called with a receiver, or a nil slipped in.

NameError

A default refers to a parameter bound later, or a misspelt local variable (undefined local variable or method 'x' for main).

TypeError

An implicit conversion failed inside the body, such as "x".strip(1): "no implicit conversion of Integer into String".

LocalJumpError

A return or yield in a proc whose method has returned, or yield with no block ("no block given (yield)").

SyntaxError

A forbidden parameter order, a duplicated name, an endless setter, or ... where it was not declared.

warning: method redefined; discarding old name

def replaced an existing method; only printed with -w.

warning: assigned but unused variable - x

A local variable (not a parameter) is never read. Prefix it with _ to silence it.

warning: possibly useless use of == in void context

An expression statement whose value is thrown away, usually a missing return or a comparison used as a statement.

warning: statement not reached

Code after a return or raise in the same body.

warning: ambiguous first argument; put parentheses or a space even after ...

p -1 (or puts *args) parsed as an operator rather than an argument; parenthesise the call.

Syntax errors and warnings

The parser (Prism, the default since Ruby 3.4) reports forbidden parameter lists with a message pointing at the offending token. The helper below compiles each definition and extracts the message, which makes it easy to learn what each mistake looks like:

# frozen_string_literal: true

# What the parser says about parameter lists that are not allowed.
def diagnose(source)
  RubyVM::InstructionSequence.compile(source)
  "valid"
rescue SyntaxError => e
  e.message[/\^~* (.*)$/, 1]
end

sources = [
  "def f(a = 1, b, c = 1); end",
  "def create_car(make:, model); end",
  "def f(&block, other); end",
  "def f(a, a); end",
  "def f(k: 1, **nil); end",
  "def f(...) = g(*)",
  "def f(*) = g(...)",
  "def setter=(value) = value",
  "def add_one value = value + 1",
  "def f(@name); end",
  "def greet(name) = puts name"
]
diagnostics = sources.to_h { [it, diagnose(it)] }
pp diagnostics

Output:

{"def f(a = 1, b, c = 1); end" => "unexpected parameter order",
 "def create_car(make:, model); end" => "unexpected parameter order",
 "def f(&block, other); end" => "unexpected parameter order",
 "def f(a, a); end" => "duplicated argument name",
 "def f(k: 1, **nil); end" =>
  "unexpected **nil; no keywords marker disallowed after keywords",
 "def f(...) = g(*)" => "unexpected `*`; no anonymous rest parameter",
 "def f(*) = g(...)" =>
  "unexpected ... when the parent method is not forwarding",
 "def setter=(value) = value" =>
  "invalid method name; a setter method cannot be defined in an endless method definition",
 "def add_one value = value + 1" =>
  "expected an `end` to close the `def` statement",
 "def f(@name); end" =>
  "invalid formal argument; formal argument cannot be an instance variable",
 "def greet(name) = puts name" => "valid"}

def create_car(make:, model) in the second line is the classic invalid signature, and the last entry shows that the command-call body of an endless method (puts name without parentheses) is accepted since Ruby 3.1.

ruby -w adds warnings about the body of a method. They are printed while the file is parsed, so the next file captures them by compiling four small definitions with $VERBOSE on; the message is the same one ruby -w writes to standard error:

# frozen_string_literal: true

require "stringio"

# Parse-time warnings that `ruby -w` prints about method bodies.
def warnings_for(source)
  original = $stderr
  $stderr = StringIO.new
  $VERBOSE = true
  RubyVM::InstructionSequence.compile(source, "demo.rb")
  $stderr.string.lines.map(&:chomp)
ensure
  $stderr = original
  $VERBOSE = false
end

unused = "def total(items)\n  sum = 0\n  items.size\nend\n"
ambiguous = "p -1\n"
void = "def check(value)\n  value == 1\n  :ok\nend\n"
unreachable = "def early\n  return 1\n  2\nend\n"

warnings_for(unused) # => ["demo.rb:2: warning: assigned but unused variable - sum"]
warnings_for(ambiguous)
# => ["demo.rb:1: warning: ambiguous first argument; put parentheses or a space even after `-` operator"]
warnings_for(void) # => ["demo.rb:2: warning: possibly useless use of == in void context"]
warnings_for(unreachable) # => ["demo.rb:3: warning: statement not reached"]

Pitfalls

  • A mutable default is fine in Ruby. def collect(book, into = []) builds a new Array on each call; the Python habit of defaulting to None and re-creating it is unnecessary. A default that names a constant is shared, however: with DEFAULT_TAGS = [] every call gets the same Array, so never mutate it in the method.

  • Optional positional parameters plus a Hash. def f(a, options = {}) still works, but f(1, x: 2) and f(1, {x: 2}) are now the same call only because no keywords are declared. Add **opts and they are different. Prefer keywords.

  • Delegating with *args only. After Ruby 3.0 a trailing keyword Hash stays a positional argument. Use ..., explicit *args, **options, &block, or ruby2_keywords.

  • return in ensure. It overrides the method’s result and swallows exceptions. Use ensure for cleanup only.

  • block_given? in a block. It reports the block of the enclosing method, not of the inner block.

  • Top-level def. It creates a private method on Object visible in every class. Fine in scripts, wrong in a gem.

  • Setters and return values. obj.value = x is x whatever the setter returns; do not rely on a setter’s return.

  • private def and endless methods. Both parse in Ruby 4.0, but older Rubies (3.0 and 3.1) rejected private def name = command argument.

  • Reserved words as keywords. if: and class: are legal parameter names but not usable as plain local variables; read them with binding.local_variable_get.

  • arity is not a count of declared parameters. Keywords count as one, optional makes it negative, and a proc differs from a lambda.

  • method_added without super. Silences every other method_added in the ancestor chain.

  • Mismatch between ? and nil. A predicate whose last expression is nil or false-ish is fine; RuboCop’s Naming/PredicateMethod and Style/ReturnNilInPredicateMethodDefinition encourage a Boolean result for new predicates.

Version notes

Version Change that concerns methods and parameters

2.0

Keyword arguments (as a separate syntax); 2.1 made def return a Symbol and required keywords.

2.7

... argument forwarding; warnings for every automatic Hash/keyword conversion.

3.0

Positional and keyword arguments separated; endless method definition; ... with leading arguments; procs with a single rest and keywords stop autosplatting.

3.1

Anonymous block parameter &; hash and keyword value omission; command calls in endless methods; Module#private, public, protected and module_function return their arguments.

3.2

Anonymous * and ** as parameters and arguments; Proc#parameters(lambda:); methods that delegate through *args need ruby2_keywords; Data.

3.3

Error messages name the receiver by class; Prism available as an alternative parser.

3.4

it block parameter; **nil allowed at call sites; messages quote methods with apostrophes; Hash#inspect prints {key: value}; Prism is the default parser.

4.0

*nil no longer calls nil.to_a; leading &&/`

` lines continue the previous line; Binding#implicit_parameters reads numbered and it parameters; Class#new is faster, notably with keyword arguments.

4.1 (preview)

ruby2_keywords (module, top level and Proc) deprecated, removal planned for 4.4. Not released as of 2026-10-18.

References