Defining Methods

Published by Programming Line in Ruby. Last updated .

A method is a reusable, named block of code that performs a task, optionally takes input, and produces a value back to whoever called it. Defining methods well is how you turn a script into a program: instead of repeating logic everywhere you need it, you give that logic a name and call it. In Ruby this is done with the def keyword, and because almost everything in Ruby is an expression with a value, method definitions have a few elegant properties — like automatic return values — that are worth understanding deeply rather than memorizing.

Overview: How Method Definitions Work

A method is defined with the def keyword, a name, an optional parameter list in parentheses, a body of one or more statements, and a closing end. Once defined, the method lives in the method table of some object or class — either the class or module you defined it inside, or, for a method defined at the very top level of a script, as a private instance method on Object itself. That’s why a top-level method can be called from anywhere later in a plain Ruby script without an explicit receiver: every object in the program ultimately inherits from Object.

Ruby has almost no bare statements, only expressions with values — if, case, and even assignment all evaluate to something. This is exactly why a method doesn’t require an explicit return: the value of the last expression evaluated in the method body automatically becomes the method’s return value. You only need return to exit early, such as in a guard clause, or to make an early exit obvious to a reader. This is also a common source of bugs — see Common Mistakes below.

Method names follow snake_case by convention, and trailing punctuation is part of the vocabulary: a name ending in ? conventionally returns a boolean (empty?, nil?), one ending in ! conventionally performs a more dangerous or mutating counterpart of a same-named method without the bang (sort! versus sort), and one ending in = defines a setter Ruby lets you call with assignment syntax. None of this is magic syntax — it’s just legal in an identifier — but the whole ecosystem leans on the convention, so breaking it will confuse readers.

Parameters come in several flavors: required positional, optional positional with a default, a splat (*args) that collects any remaining positional arguments into an array, keyword arguments (with or without defaults), a double splat (**kwargs) that collects remaining keyword arguments into a hash, and a trailing block parameter (&block) that captures a passed block as a Proc. Default value expressions are evaluated at call time, left to right, only for parameters that weren’t supplied — a default can even reference an earlier parameter in the same list. Since Ruby 3.0, a method whose entire body is one expression can be written as an endless method: def square(x) = x * x, with no end at all. Reserve this for genuine one-liners; anything with a conditional, loop, or multiple statements should use the full def ... end form.

Finally, a method body opens a brand-new local variable scope. Local variables from the calling code are invisible inside the method unless passed in as arguments — unlike a block, which shares its enclosing scope. That isolation is deliberate: it lets you call any method without worrying it will silently read or overwrite a local variable of the same name somewhere else in your program.

Syntax

The general shape of a method definition looks like this:

def method_name(param1, param2 = default, *splat_args, keyword: default, **double_splat, &block)
  # method body
  last_expression_value
end
Part Meaning
def / end Keywords that open and close the method body.
method_name snake_case identifier; may end in ?, !, or = by convention.
param1 A required positional parameter — the caller must supply it.
param2 = default An optional positional parameter; default is computed only if the caller omits it.
*splat_args Collects any extra positional arguments into an array.
keyword: default A keyword argument; omit the value after the colon to make it required.
**double_splat Collects any extra keyword arguments into a hash.
&block Captures a block passed with { } or do...end as a Proc object.
last_expression_value Whatever this evaluates to is returned automatically — no explicit return needed.

An endless method skips end entirely and requires the whole body to be a single expression: def name(args) = expression.

Examples

Example 1: A Basic Method with an Implicit Return

def greet(name)
  "Hello, #{name}!"
end

puts greet("Ruby")
Output:
Hello, Ruby!

greet takes one required parameter, builds a string with interpolation, and that string — being the last (and only) expression in the body — becomes the return value. No return keyword appears anywhere, which is idiomatic Ruby.

Example 2: Keyword Arguments with Defaults

def rectangle_area(width:, height: 1)
  width * height
end

puts rectangle_area(width: 5, height: 3)
puts rectangle_area(width: 4)
Output:
15
4

width: has no default, so it’s required; height: defaults to 1 if the caller doesn’t pass it. The first call supplies both keywords and returns 5 * 3. The second call omits height:, so Ruby computes the default and returns 4 * 1. Keyword arguments make the call site self-documenting — you can tell what 5 and 3 mean without checking the method definition.

Example 3: Splat, Double Splat, and Extra Keywords

def order_summary(*items, discount: 0, **extra_info)
  total = items.reduce(0) { |sum, (_, price)| sum + price }
  puts "Items: #{items.map { |(name, _)| name }.join(', ')}"
  puts "Total before discount: $#{total}"
  puts "Total after discount: $#{total - discount}"
  extra_info.each { |key, value| puts "#{key}: #{value}" }
end

order_summary(["Book", 20], ["Pen", 2], discount: 5, gift_wrap: true)
Output:
Items: Book, Pen
Total before discount: $22
Total after discount: $17
gift_wrap: true

The two positional arrays are collected into items by the splat. discount: 5 matches the declared keyword, while gift_wrap: true has no matching parameter, so it lands in extra_info via the double splat. Note the block parameters |sum, (_, price)| and |(name, _)|: Ruby automatically destructures an array yielded to a block, so each [name, price] pair splits into two locals in one step.

Example 4: An Endless Method

def cube(x) = x ** 3

puts cube(3)
puts cube(-2)
Output:
27
-8

Because the whole body is the single expression x ** 3, this reads naturally as a one-line definition with no end. Endless methods work well for short, pure computations like this one, but a body with an if, a loop, or several statements should stay in the full def ... end form.

How It Works Step by Step

When Ruby encounters a def, it does not run the body — it compiles the method and stores it on the method table of whatever object or class is currently open. Nothing inside the body executes until the method is actually called. A call then proceeds in a fixed order:

  1. Ruby looks up the method by name along the receiver’s method resolution order (its singleton class, then its class, included modules in reverse inclusion order, then superclasses, up to BasicObject) and checks the arguments you passed against what the definition requires. A missing required argument or an unrecognized keyword raises ArgumentError immediately, before the body ever runs.
  2. Arguments are bound to parameter names left to right: required positional parameters first, then optional ones (whose default expressions are computed now, only if nothing was supplied for them), then a splat absorbs any leftover positional arguments into an array, keyword arguments are matched by name, unmatched keywords fall into a double splat, and a block passed with { } or do...end is wrapped in a Proc and bound to an &block parameter if one exists.
  3. A fresh local variable scope opens for the method body, containing only the bound parameters — nothing from the caller’s scope leaks in.
  4. The body executes top to bottom. Because if, case, and similar constructs are expressions, whichever branch runs contributes its own value to the flow.
  5. When the last statement finishes (or an explicit return runs), that value becomes the method’s return value, and control passes back to the caller exactly as if the whole call had been replaced by that value.

Tracing rectangle_area(width: 4) from Example 2: Ruby confirms width: was supplied and computes the default height = 1 since it wasn’t; the body evaluates 4 * 1; that 4 is the last expression, so it’s returned and printed.

Common Mistakes

Mistake 1: Forgetting the Last Expression Is the Return Value

It’s easy to add a puts as the last line of a method for debugging and forget that puts itself returns nil — so the method now silently returns nil instead of the value you meant to compute.

def add(a, b)
  sum = a + b
  puts "Sum computed"
end

result = add(2, 3)
puts result.inspect
Output:
Sum computed
nil

The assignment to sum isn’t the last statement, and puts "Sum computed" is — and puts always evaluates to nil. The fix is to make sure the value you want to return is genuinely the last expression:

def add(a, b)
  a + b
end

result = add(2, 3)
puts result.inspect
Output:
5

Mistake 2: Writing = Instead of == in a Condition

Since only nil and false are falsy in Ruby, a stray assignment inside a condition doesn’t raise an error — it just silently evaluates to a truthy value every time, so the branch always runs.

def adult?(age)
  if age = 18
    true
  else
    false
  end
end

puts adult?(5)
puts adult?(30)
Output:
true
true

age = 18 reassigns the parameter to 18 and evaluates to 18, which is truthy, so the if branch always wins regardless of the argument passed in. Ruby will actually emit a warning about assignment in a conditional, but it is still valid, runnable code — a real trap for anyone skimming past a warning. The fix is a genuine comparison:

def adult?(age)
  age >= 18
end

puts adult?(5)
puts adult?(30)
Output:
false
true

Mistake 3: Assuming Arguments Are Copied

Ruby passes object references, not copies. A method that calls a mutating method like << on an array argument changes the caller’s original object, which can be surprising if you didn’t intend that.

def add_item(list, item)
  list << item
end

original = [1, 2, 3]
add_item(original, 4)
puts original.inspect
Output:
[1, 2, 3, 4]

original changed even though it was never reassigned at the call site, because list inside the method refers to the very same array object. If that’s not what you want, build and return a new array instead of mutating the one you were given:

def add_item(list, item)
  list + [item]
end

original = [1, 2, 3]
new_list = add_item(original, 4)
puts original.inspect
puts new_list.inspect
Output:
[1, 2, 3]
[1, 2, 3, 4]

Best Practices

  • Prefer the implicit return; reserve explicit return for early exits and guard clauses, where it signals intent clearly.
  • Use keyword arguments once a method takes more than one or two parameters, especially booleans — resize(width: 100, keep_aspect: true) is far clearer at the call site than resize(100, true).
  • Follow the ? / ! / = naming conventions, and make sure a bang method’s behavior genuinely differs from (and is riskier than) its non-bang counterpart.
  • Keep methods small and focused on one responsibility — a method that’s hard to name in a few words is usually doing too much.
  • Reserve endless method syntax for true one-liners; anything with a conditional or multiple statements belongs in a full def ... end.
  • Give optional parameters sensible defaults rather than defaulting to nil and checking for it inside the body.
  • Avoid mutating an argument you were handed unless the method’s name or documentation makes that mutation obvious.
  • Since Ruby has no static type signatures, use descriptive parameter names and, for anything non-obvious, a short comment on the expected type or shape.

Practice Exercises

  • Write a method temperature_message(celsius:) that uses case/when to return "Freezing" for 0 or below, "Cold" for 1 to 15, "Warm" for 16 to 25, and "Hot" above 25. Call it with a few sample temperatures and check the results make sense.
  • Write an endless method average(a, b) that returns the average of two numbers. Hint: average(4, 10) should return 7.0.
  • Write a method build_profile(name, *hobbies, **details) that prints the name, a comma-joined list of hobbies, and any extra keyword details, one per line. Try calling it as build_profile("Sam", "chess", "hiking", city: "Austin") and predict the output before you run it.

Summary

  • A method is defined with def name(params) ... end, or as a one-line def name(params) = expression since Ruby 3.0.
  • The value of the last expression evaluated in the body is returned automatically — no explicit return is required.
  • Parameters can be required, optional with defaults, splatted (*args), keyword-based, double-splatted (**kwargs), and can capture a block with &block.
  • Only nil and false are falsy in Ruby, which is why an accidental assignment inside a condition is truthy and doesn’t error out.
  • A method body has its own local variable scope, but arguments are passed by reference, so mutating an argument in place affects the caller’s object.
  • Trailing ?, !, and = in method names are conventions, not enforced rules — use them consistently so your code reads the way experienced Ruby developers expect.