Written by James BrittTechnically reviewed by Jim Freeze · Reviewed
Ruby functional programming uses functions and data transformations to make behaviour easier to follow and reduce unexpected changes to shared state. Ruby supports this style through methods, blocks, lambdas, and operations available on collections. These tools can be used in a conventional Ruby or Rails app without forcing the entire code base into a completely different type of program.
Start with a calculation that includes reading data from databases, doing some arithmetic, and saving updated data. Create a method that explicitly takes arguments and returns something, leaving database reads and saves outside it. Then look at the calculation without having to prepare a database or figure out which objects were modified.

Start with a method that returns a value
A pure function returns the same output given the same input and has no observable side effects. When writing Ruby, try for a method that leaves both its input(s) and any external state unchanged. Try to avoid hiding any dependency (such as the current date/time) in the method. This example calculates a total amount based upon integer amounts expressed in cents:
def total_cents(lines)
lines.sum do |line|
line.fetch(:unit_cents) * line.fetch(:quantity)
end
end
items = [
{ unit_cents: 1250, quantity: 2 },
{ unit_cents: 300, quantity: 1 }
]
p total_cents(items) # 2800
The above example calls the method with an array of two records. Each record contains both a :unit_cents and a :quantity. It adds up the products of these numbers, where the product of each pair of numbers represents the total amount for that line. After calling the method, neither record nor external state is changed by this calculation under the stated input contract. The caller supplies all necessary integers for this example’s contract. This example performs only arithmetic. It is not a complete pricing or accounting system.
fetch raises an exception instead of silently returning nil if it attempts to retrieve an absent required key. You still must establish a policy for invalid values. You could choose to refuse them at an input boundary or validate them inside the method. Do not hide either choice behind a convenient conversion.
Ruby functional programming with Enumerable
Ruby’s Enumerable documentation describes operations for selecting, transforming and combining items. A short pipeline can make those separate jobs visible:
invoices = [
{ paid: true, total_cents: 2800 },
{ paid: false, total_cents: 900 },
{ paid: true, total_cents: 1500 }
]
paid_cents = invoices
.select { |invoice| invoice.fetch(:paid) }
.map { |invoice| invoice.fetch(:total_cents) }
p paid_cents # [2800, 1500]
p paid_cents.sum # 4300
combined = paid_cents.reduce(0) do |sum, cents|
sum + cents
end
p combined # 4300
This is equivalent to three steps: select paid invoices, extract their totals, then sum those totals. The explicit initial value in reduce(0) also ensures that the empty case will always return zero. For simple addition, sum will express the intent more clearly.
Keep the input shape clear. The example above used booleans for paid. An unchecked string field containing “false”, however, is still truthy in Ruby. Therefore, if you did not check the field correctly, you would get a different answer. Data validation and doing a calculation are distinct jobs.
Ruby functional programming and mutation
Collection operations encourage a transformation style of programming, but each block still determines what happens to its objects. As shown below, the result is trimmed strings while the original strings remain unaltered:
names = [" Ada ", " Matz "]
clean_names = names.map { |name| name.strip }
p names # [" Ada ", " Matz "]
p clean_names # ["Ada", "Matz"]
If each string in the block were altered in place, then creating a new result array would not protect the original strings. While reviewing your code, consider both the container operation you’re using and the operations contained within each block. There’s still potential for creating a new container while sharing some mutable objects among the new and old containers.
An exclamation point usually indicates a more hazardous option that may include mutation. It doesn’t provide a universal indicator for side effects. Instead of relying solely on the method name, examine its contract.
Freeze is useful, but it is shallow
The Object#freeze documentation explains how to prevent changes to an object. Freezing a container does not recursively freeze every object it references:
record = { tags: ["new"] }.freeze
record.fetch(:tags) << "checked"
p record.fetch(:tags) # ["new", "checked"]
The hash is frozen, but the array stored under :tags remains mutable. Replacing the hash entry would fail; changing that nested array does not. This distinction matters when a supposedly fixed configuration contains arrays, hashes or strings.
For a smaller input structure, decide which objects must stay fixed and intentionally create and freeze them yourself. Working with much larger structures requires you to define ownership of the data before adding additional deep-freeze helpers throughout your application. Both copying and freezing incur costs as well. They don’t eliminate the necessity for a clear data contract.
Pass behaviour with a lambda
A lambda is a callable Proc with lambda-specific argument and return behaviour. The Proc reference documents those differences. A lambda is useful when a method needs a small piece of behaviour rather than a fixed value:
add_handling = ->(cents) { cents + 75 }
p [100, 200].map(&add_handling) # [175, 275]
The & symbol passes the callable as a block to map. The example has one input parameter and adds a fixed handling amount to it. A named method would have been equally valid if the operation had a meaningful place in the application.
Both blocks and lambdas can capture variables from their surrounding scope as closures. Captured values can mask changing inputs. When a captured configuration value changes then a previously specified set of explicit arguments may produce different results. Pass changing dependencies explicitly when predictable behavior is important.
Use lazy evaluation when you need a bounded result
A long chain of eager operations may build intermediate arrays. Enumerator::Lazy lets you defer parts of a pipeline until values are requested:
odd_squares = (1..).lazy
.select { |number| number.odd? }
.map { |number| number * number }
.take(4)
.force
p odd_squares # [1, 9, 25, 49]
There is no defined maximum value for the range. The pipeline stops once it reaches four matches as requested. That terminating condition is very important — indefinitely producing an infinite sequence without knowing when it will stop would never terminate.
Lazy evaluation does not automatically cause parallel execution nor necessarily speed up processing for small datasets. Use lazy evaluation when delayed consumption resolves a particular issue, then evaluate memory usage and run times. Also verify that each operation in the pipeline maintains the level of laziness that you require.
Explore a project situation
Open the situation closest to your work. Compare the alternatives before choosing a first experiment.
A calculation is difficult to test
Extract a method that accepts plain input and returns a value. Check the empty case and confirm the input stays unchanged.
A pipeline unexpectedly changes its source
Inspect the container method and every block operation. A new array can still contain shared mutable objects.
A sequence is large or endless
Use lazy operations with a clear consumption bound. Confirm the chain terminates and produces the expected values.
Ruby functional programming in a Rails workflow
Real-world applications typically read files, write records to databases and send emails. Functional styles of programming do not preclude these types of jobs; however they help separate those jobs from calculations making it easier to analyze the order of side effects.
Inside a Rails feature, a controller or job may gather records from a database, create plain data for input into a calculation and subsequently act on the result of that calculation. Calculations may be tested separately. The overall process still needs integration testing for authorization, transactions and error handling.
Start with one troublesome calculation and compare the before-and-after clarity. Avoid replacing straightforward code with long chains of cryptic blocks. The next developer should be able to explain what data enters, what value leaves and what state changes. For more small programs to practise with, see our Ruby code examples.
For the total method, test an empty collection, multiple line items and a missing required key. Preserve a copy of the original input and compare it with the input after the call to check for mutation. For lazy pipelines, test the generated values as well as the count returned. These checks cover arithmetic, input contracts, mutation and termination. A long list of assertions simply mirroring each line of implementation would give less assurance against refactoring your calculations.
Frequently asked questions
Is Ruby a purely functional language?
No. Ruby supports mutable objects and side effects in addition to supporting various tools designed specifically for functional programming style. You can incorporate specific aspects of functional programming techniques within Ruby’s object-oriented paradigm.
Does map make code pure?
No. The block passed to map can modify objects, read changing external state or perform I/O operations; therefore review what operations occur within the block itself versus merely examining the name of the collection method invoked.
Should every method become a lambda?
No. Named methods generally aid discovery and explanation better than anonymous ones. Consider using a lambda when passing or storing behavior aids clarity in surrounding code.
