PONY λ M2 Modula-2

Ruby.CodeCompared.To/Erlang

An interactive executable cheatsheet for Rubyists learning Erlang

Ruby 4.0 Erlang/OTP 26
Output & Running
Hello, World
Ruby's puts writes its argument followed by a newline automatically.
puts "Hello, World!"
io:format("Hello, World!~n").
Erlang prints with io:format/1, and the newline is not automatic — the ~n directive inside the format string produces it. Notice the trailing period: it terminates the whole expression, the way a Rubyist might end a statement, except Erlang uses it only at the very end of a sequence of expressions, with commas between the steps.
Printing a value for inspection
Ruby's p calls inspect on its argument and prints the result — "show me this value, structure and all".
response = [200, :ok, "cached"] p response
Response = {200, ok, "cached"}, io:format("~p~n", [Response]).
Erlang's ~p ("pretty print") directive renders any term for humans, the direct counterpart of p. The value here also previews two Erlang staples: a tuple — a fixed-size, ordered group of values, here a status code, an atom, and a string — is the everyday way to bundle a few related things, and the arguments to io:format are passed as a list in the second position, so the format string and its data stay separate.
Comments
Ruby has a line comment with # and a block comment as the =begin/=end pair.
# a single-line comment =begin a block comment =end puts "commented"
% a single-line comment — Erlang has only this one form io:format("commented~n").
Erlang has a single comment form: % runs to the end of the line, and there is no block-comment syntax at all, so a multi-line comment is just several % lines. Convention doubles the marker (%%) for a comment that describes the code below it and a single % for an aside at the end of a line, but that is style, not a language rule.
Binding & Pattern Matching
"=" is a match, not assignment
In Ruby = assigns: the name on the left is bound to the value on the right, and can be rebound later.
x = 41 puts x + 1
X = 41, io:format("~p~n", [X + 1]).
In Erlang = is the match operator, not assignment. Variables start with a capital letter, and X = 41 asserts that the left side matches the right: since X is unbound, the match succeeds by binding it. This one operator does double duty as both "bind" and "assert equal", which is the foundation of the pattern matching in the rest of this section — the same = that binds a name can also take a value apart.
Single assignment
Ruby variables are freely reassignable: x can be 1 now and 2 a line later, and everything holding x tracks the change.
x = 1 x = 2 puts x
% X = 2 here would CRASH with a badmatch — X is already 1: X = 1, io:format("~p~n", [X]).
An Erlang variable is bound exactly once. Because = is a match, writing X = 2 after X = 1 does not reassign — it asserts that 1 equals 2, which fails with a badmatch error and stops the program. This "single assignment" rule is the surprise that trips up every Rubyist first, and it is deliberate: a value you have named can never change under you, so there is no such thing as stale state or a variable meaning two things in one scope.
Destructuring by matching
Ruby destructures with parallel assignment: x, y = point pulls an array apart into two names.
point = [3, 4] x, y = point puts "#{x}, #{y}"
Point = {3, 4}, {X, Y} = Point, io:format("~p, ~p~n", [X, Y]).
Erlang destructures with the same = you just met: {X, Y} = Point matches a two-element tuple, binding X and Y as it goes. It looks like Ruby's parallel assignment, but it is stricter and more powerful at once — the shapes must line up exactly (a three-element tuple would badmatch here rather than silently drop a value), and you can put literals in the pattern to match and assert in a single step, such as {ok, Value} = ....
Atoms vs Symbols
Atoms are (almost) symbols
A Ruby symbol like :ok is an interned, identity-compared name — lightweight and used for statuses, keys, and flags.
status = :ok puts "#{status.inspect} — symbol? #{status.is_a?(Symbol)}"
Status = ok, io:format("~p — atom? ~p~n", [Status, is_atom(Status)]).
An Erlang atom is the same idea under a different name: a constant whose value is just itself, interned and compared by identity. The one visible difference is the punctuation — a Ruby symbol wears a leading colon (:ok) while an Erlang atom is a bare lowercase word (ok), which is exactly why Erlang variables must be capitalized: the capitalization is what distinguishes a variable from an atom. Atoms carry far more weight in Erlang than symbols do in Ruby — true, false, ok, and error are all just atoms.
Data, Not Objects
Tagged tuples vs objects
A Rubyist reaches for an object with named readers; Data.define gives an immutable value with fields.
Person = Data.define(:name, :age) alice = Person.new(name: "Alice", age: 30) puts "#{alice.name} is #{alice.age}"
Person = {person, "Alice", 30}, {person, Name, Age} = Person, io:format("~s is ~p~n", [Name, Age]).
Erlang has no objects, no classes, and no methods. The idiomatic stand-in for a small record is a tagged tuple{person, "Alice", 30}, a tuple whose first element is an atom naming its shape — which you take apart by matching. The data carries no behavior at all; functions that operate on a person live separately in a module and receive the tuple as an argument. This split between inert data and free functions is the deepest structural difference from Ruby, where data and the methods that act on it are bundled together in an object.
Maps vs hashes
Ruby's Hash is the everyday key–value store, indexed with [].
specs = { cpu: 4, memory: 16 } puts specs[:cpu]
Specs = #{cpu => 4, memory => 16}, io:format("~p~n", [maps:get(cpu, Specs)]).
Erlang's map is the close counterpart, written with the #{...} syntax and read with functions from the maps module rather than an index operator — maps:get/2 here. The keys are atoms (cpu, memory), the same role Ruby's symbol keys play in { cpu: 4 }. Like everything in Erlang the map is immutable — maps:put/3 returns a new map rather than changing the original — and a missing key raises with maps:get/2, so the safe-lookup habit is maps:get/3 with a default or maps:find/2, which returns an {ok, Value}/error result you match on.
No mutation
Ruby data is mutable: << pushes onto the same array in place, and every reference to it sees the change.
numbers = [1, 2, 3] numbers << 4 p numbers
% Nothing mutates; "adding" builds a new list: Numbers = [1, 2, 3], Extended = Numbers ++ [4], io:format("~p then ~p~n", [Numbers, Extended]).
Nothing in Erlang can be mutated in place. "Adding" to a list means building a new one with ++ and binding it to a new name, while the original stays exactly as it was — note how Numbers is still [1, 2, 3] afterward. This removes the aliasing surprise a Rubyist knows well, where two variables point at the same array and a push through one is silently seen through the other. Combined with single assignment, it means no value in the program ever changes once created — the property that makes Erlang's share-nothing processes safe.
{ok, _} vs nil & Exceptions
Absence & failure without nil
Ruby returns nil for "nothing here", and the caller may or may not remember to check for it.
def lookup(key) = key == :answer ? [:ok, 42] : [:error, :not_found] p lookup(:answer)
Lookup = fun(answer) -> {ok, 42}; (_) -> {error, not_found} end, io:format("~p~n", [Lookup(answer)]).
Erlang has no nil. The pervasive convention is to return a tagged tuple — {ok, Value} on success, {error, Reason} on failure — that the caller takes apart by matching. The value here also shows a fun with two clauses matching different argument patterns (answer versus the catch-all _), which is how Erlang expresses what a Rubyist would write as an if or a ternary. Because the result is a value with a shape, the "forgot to check for nil" bug becomes a match you cannot skip.
Handling an {ok, _} / {error, _}
A Rubyist handles a status-tagged result with case/in, matching the array shape and binding the payload.
result = [:ok, 42] case result in [:ok, value] then puts "Got #{value}" in [:error, reason] then puts "Failed: #{reason}" end
Result = {ok, 42}, case Result of {ok, Value} -> io:format("Got ~p~n", [Value]); {error, Reason} -> io:format("Failed: ~p~n", [Reason]) end.
Erlang's case is the natural home of this idiom, and it reads almost exactly like Ruby's case/in: each clause is a pattern that both selects the branch and binds the payload, with clauses separated by semicolons and the whole thing closed by end. The difference from Ruby is that this structural matching is not a newer addition bolted onto the language but its primary control-flow tool — you will reach for case and multi-clause functions far more than for anything resembling an if.
Exceptions & "let it crash"
Ruby leans on exceptions: raise unwinds the stack and begin/rescue catches a specific error class.
begin puts 1 / 0 rescue ZeroDivisionError => error puts "caught: #{error.message}" end
try 1 div 0 of Result -> io:format("~p~n", [Result]) catch error:badarith -> io:format("caught a bad arithmetic error~n") end.
Erlang has try/catch too, and it matches on the class and value of the failure (error:badarith here) much as Ruby matches an exception class. But the Erlang philosophy is to use it sparingly: rather than defensively rescuing, you "let it crash" and let a supervisor restart the failed process in a known-good state (see the Processes section). Where a Rubyist wraps risky code in begin/rescue to keep one thread alive, an Erlang programmer more often lets a small isolated process die cleanly and be reborn.
Funs, Recursion & Modules
Anonymous functions
Ruby writes a lambda as ->(number) { ... }, a first-class object called with .call.
double = ->(number) { number * 2 } puts double.call(21)
Double = fun(N) -> N * 2 end, io:format("~p~n", [Double(21)]).
An Erlang fun is the same first-class function value, written fun(N) -> N * 2 end and applied by simply naming it with parentheses, Double(21). Erlang has no blocks and no distinction between a passed function and a special callback slot — a fun is just a value you can bind, pass, and call anywhere, so the &/yield/block_given? machinery a Rubyist juggles simply has no equivalent here.
Higher-order functions
Ruby maps over a collection with a block: [1,2,3,4].map { |n| n * n }.
squared = [1, 2, 3, 4].map { |number| number * number } p squared
Squared = lists:map(fun(N) -> N * N end, [1, 2, 3, 4]), io:format("~p~n", [Squared]).
Erlang's lists:map/2 takes the function first and the list second, passing a fun where Ruby passes a block. The operations you know from Enumerable mostly live in the lists module as plain functions — lists:filter/2, lists:foldl/3, lists:sort/1 — called on the list rather than dotted off it. There is no method chaining, since lists are not objects with methods; you either nest the calls or thread the value through a comprehension, which the next section shows.
Recursion instead of loops
Ruby offers recursion but usually prefers a loop or an iterator like times or reduce.
def factorial(number) = number <= 1 ? 1 : number * factorial(number - 1) puts factorial(5)
Factorial = fun Fact(0) -> 1; Fact(N) -> N * Fact(N - 1) end, io:format("~p~n", [Factorial(5)]).
Erlang has no for or while loop and no mutation, so repetition is recursion — here a named fun (fun Fact(...) -> ... end) so it can call itself. Note the two clauses separated by a semicolon: Fact(0) matches the base case and Fact(N) the rest, so the pattern in the function head replaces the if a Rubyist would write inside the body. Real Erlang leans hard on this — most "loops" are multi-clause recursive functions, and the runtime optimizes tail-recursive ones so they never grow the stack.
Defining a module
A Rubyist groups behavior in a class or module and calls it by name. (The Erlang side here is real module-file syntax; it is display-only because the in-browser runner evaluates loose expressions, not compiled module files — the runnable examples on this page use funs for that reason.)
module MathUtils def self.double(number) = number * 2 end puts MathUtils.double(21)
-module(math_utils). -export([double/1]). double(N) -> N * 2.
This is what real Erlang code looks like on disk: a file declares its -module name, lists the functions it makes public with -export, and defines top-level named functions with pattern-matched clauses — no fun ... end wrapper needed. A module is purely a namespace for functions; there is nothing to instantiate and nothing to mix in, so it is closer to a Ruby module of self. methods than to a class. Calls are always qualified by the module, as in math_utils:double(21).
List Comprehensions
List comprehensions
A Rubyist chains select and map to filter then transform a collection.
evens = (1..5).select(&:even?).map { |number| number * number } p evens
Evens = [N * N || N <- [1, 2, 3, 4, 5], N rem 2 =:= 0], io:format("~p~n", [Evens]).
Erlang has a built-in list comprehension that folds the filter and the transform into one expression: [N * N || N <- List, N rem 2 =:= 0] reads as "N squared, for each N drawn from the list, where N is even". The <- is a generator and the trailing condition is a filter — a compact, readable alternative to chaining lists:filter/2 and lists:map/2. Note =:=, Erlang's exact-equality operator; it is stricter than == and does not treat the integer 2 and the float 2.0 as equal.
Folding a list
Ruby collapses a list into one value with reduce, passing the accumulator first into the block.
total = [10, 20, 30].reduce(0) { |sum, number| sum + number } puts total
Total = lists:foldl(fun(N, Acc) -> N + Acc end, 0, [10, 20, 30]), io:format("~p~n", [Total]).
Erlang's lists:foldl/3 is the counterpart to reduce, taking the folding function, the starting accumulator, and the list. The argument order differs from Ruby — the function comes first and the list last — and the accumulator is the second parameter of the fun, not the first, so it pays to read the signature rather than assume Ruby's ordering. There is also a lists:foldr/3 that folds from the right, and lists:sum/1 for this specific case.
Processes & Message Passing
Spawning a process
A Rubyist reaches for a Thread and a Queue to hand a job to another line of execution. (Under ruby.wasm these run synchronously, but the shape is real.)
jobs = Queue.new printer = Thread.new do document = jobs.pop puts "printing #{document}" end jobs << "invoice.pdf" printer.join
Printer = spawn(fun() -> receive {print, Document} -> io:format("printing ~s~n", [Document]) end end), Printer ! {print, "invoice.pdf"}.
This is why a Rubyist would learn Erlang. spawn starts a fully isolated, independently garbage-collected process — far lighter than an OS thread, thousands or millions to a system — and hands back its Pid (bound here to Printer). Processes share no memory; they communicate only by sending messages with !, and a process blocks in receive until a message matches one of its patterns. There are no locks anywhere because there is no shared state to protect, so the whole category of race condition a Ruby Mutex exists to prevent simply does not arise.
State lives in a process
In Ruby, mutable state lives in an object: a Counter holds an @count that any caller can change through its methods.
class Counter def initialize = @count = 0 def add(amount) = @count += amount def total = @count end counter = Counter.new counter.add(5) counter.add(3) puts "Total: #{counter.total}"
Loop = fun Loop(Count) -> receive {add, N, From} -> From ! {total, Count + N}, Loop(Count + N) end end, Server = spawn(fun() -> Loop(0) end), Server ! {add, 5, self()}, receive {total, _} -> ok end, Server ! {add, 3, self()}, receive {total, Final} -> io:format("Total: ~p~n", [Final]) end.
Erlang has no mutable variables, so state cannot live in an object field — it lives in a process. A recursive receive loop carries the current count as an argument, and each message produces the next state by calling itself with a new value (Loop(Count + N)); nothing is ever mutated. The only way to read or change that state is to send the process a message, which means the state is reachable from exactly one place and can never be corrupted by a concurrent caller. This process-holds-state pattern is the seed of OTP's gen_server, the backbone of real Erlang systems.
Let it crash & supervision
In Ruby, keeping a worker alive means wrapping its body in begin/rescue and deciding then and there how to recover.
worker = Thread.new do begin raise "boom" rescue => error puts "handled inline: #{error.message}" end end worker.join
process_flag(trap_exit, true), Child = spawn_link(fun() -> exit(boom) end), receive {'EXIT', Child, Reason} -> io:format("child died with: ~p; supervisor can restart it~n", [Reason]) end.
Erlang's answer to failure is structural rather than inline. A process links to another with spawn_link, and when the child crashes (here an explicit exit(boom)) the runtime sends the linked parent an {'EXIT', Pid, Reason} message instead of letting the error propagate into it. A parent that has called process_flag(trap_exit, true) receives that message and can restart the child in a fresh, known-good state. That is "let it crash": you do not defensively rescue every operation, you isolate work in small processes and let a supervisor above them handle death — a resilience model Ruby has no built-in equivalent for.
Guards & Control Flow
case with guards
A Rubyist branches with if/elsif/else, testing each condition in turn.
def classify(number) if number > 0 :positive elsif number < 0 :negative else :zero end end p classify(-5)
Classify = fun(N) when N > 0 -> positive; (N) when N < 0 -> negative; (_) -> zero end, io:format("~p~n", [Classify(-5)]).
Erlang folds the conditions into the function head as guards: fun(N) when N > 0 -> ... means "this clause applies only when N is positive". The clauses are tried top to bottom, and the catch-all (_) plays the role of else. Guards are restricted to a fixed set of side-effect-free tests (comparisons, type checks like is_integer/1, arithmetic), so you cannot call an arbitrary function in one — a limitation that keeps matching predictable and fast.
The if expression
Ruby's if is an expression whose else is optional; with no matching branch it yields nil.
grade = 85 result = if grade >= 90 :excellent elsif grade >= 80 :good else :other end puts result
Grade = 85, Result = if Grade >= 90 -> excellent; Grade >= 80 -> good; true -> other end, io:format("~p~n", [Result]).
Erlang's if is a guard-driven expression, and it is stricter than Ruby's in a way that catches newcomers: every branch is a guard, and one of them must succeed, so the conventional final branch is the literal guard true -> serving as the catch-all. There is no bare else, and omitting the true branch when nothing else matches raises an error rather than quietly producing nil — Erlang would rather fail loudly than hand back a nothing you did not ask for. In practice Erlang code uses case and function-head guards far more than if.
Gotchas for Rubyists
Strings are lists of integers
In Ruby a string is a first-class object with a hundred methods — "hello".upcase, "hello".length.
p "hello".bytes
% A double-quoted "string" is secretly a list of character codes: Value = "hello", io:format("~w~n", [Value]).
A double-quoted Erlang string is not a distinct type at all — it is a plain list of integer character codes, which is why ~w (raw term output) prints [104,101,108,108,111] rather than the letters. Every list function works on it, but there is no string object and none of Ruby's string methods. For real text-heavy work Erlang programs use binaries instead, written <<"hello">>, which are compact and are what libraries expect; the list-of-codes form survives mostly for small literals and history.
No truthiness
Ruby treats every value as a boolean in a condition, and only nil and false are falsy — so 0 and "" are truthy.
p [0, "", nil].map { |value| value ? "truthy" : "falsy" }
% Only the atom false is false; there is no truthiness: io:format("~p~n", [(1 =:= 1) andalso (2 =:= 2)]).
Erlang has no truthiness whatsoever. true and false are ordinary atoms, and the boolean operators andalso, orelse, and not accept only those two atoms — a number or a list in a boolean position is an error, not a shortcut. The Ruby idioms that lean on truthy values (value || default, guarding on a possibly-nil object) have no equivalent; their jobs move to explicit pattern matching on {ok, _}/{error, _} and to guards. Nothing is ever coerced to a boolean behind your back.