PONY λ M2 Modula-2

Ruby.CodeCompared.To/Elixir

An interactive executable cheatsheet for Rubyists learning Elixir

Ruby 4.0 Elixir 1.17
Hello World & Immutability
Hello, World
puts "Hello, World!"
IO.puts("Hello, World!")
Both languages print to standard output with a one-word call. Elixir's IO.puts/1 lives in the IO module — there is no top-level puts the way Ruby has Kernel#puts — but the call reads almost identically. The /1 you will see in Elixir docs means "arity one": functions are identified by name and argument count.
Data never mutates
numbers = [1, 2, 3] numbers << 4 # mutates the array in place puts numbers.inspect # => [1, 2, 3, 4]
numbers = [1, 2, 3] more = numbers ++ [4] # builds a NEW list IO.inspect(numbers) # unchanged IO.inspect(more)
This is the deepest difference between the two languages. In Ruby, << mutates the array in place. Elixir values are immutable: ++ builds a brand-new list and the original numbers is untouched. Nothing you can write in Elixir will ever change a value another part of the program is holding — the foundation that makes its concurrency safe.
Rebinding a name is not mutation
count = 1 count = count + 1 puts count
count = 1 count = count + 1 IO.puts(count)
The line count = count + 1 looks the same in both, but means something different. In Ruby the variable's object may change; in Elixir the value 1 is immutable and can never change — you are simply rebinding the name count to a new value. The old value is untouched; anything else pointing at it still sees 1.
Everything is an expression
def classify(number) if number.even? "even" else "odd" end end puts classify(4)
defmodule Math do def classify(number) do if rem(number, 2) == 0 do "even" else "odd" end end end IO.puts(Math.classify(4))
Like Ruby, Elixir has no return keyword in idiomatic code — a function returns the value of its last expression, and if is an expression that evaluates to one branch or the other. A Rubyist already writes this way; Elixir simply makes it the only way.
Assignment Is Matching
The match operator
pair = [1, 2] first, second = pair puts first puts second
pair = [1, 2] [first, second] = pair IO.puts(first) IO.puts(second)
Elixir's = is the match operator, not assignment. The right-hand side is matched against the pattern on the left, binding any variables in it. Ruby's parallel assignment first, second = pair is the closest analog, but in Elixir the left side is a full structural pattern: the [ ] is required, and the shapes must line up or the match fails.
Destructuring a map
person = { name: "Ada", born: 1815 } name = person[:name] puts name
person = %{name: "Ada", born: 1815} %{name: name} = person IO.puts(name)
A map pattern matches on a subset of keys, binding exactly what you name. This is the everyday way to pull fields out of a map in Elixir, and it is the same mechanism used in function arguments, case, and with — one idea reused everywhere.
A match can fail
# Ruby just assigns whatever is there: status, value = [:error, "boom"] puts status # => error
# {:ok, value} only matches a 2-tuple tagged :ok {:ok, value} = {:ok, 42} IO.puts(value) # 42 # This would raise MatchError — uncomment to see: # {:ok, value} = {:error, "boom"}
Because = asserts a shape, a mismatch raises a MatchError rather than silently binding nil. Matching against a tagged tuple like {:ok, value} is the idiomatic way to say "I expect success, and I want the payload" — the pattern doubles as a runtime assertion.
The pin operator ^
expected = 42 actual = 42 puts(expected == actual)
expected = 42 # ^ means "match against the current value of expected", # don't rebind it: ^expected = 42 IO.puts("matched 42") # ^expected = 99 # would raise MatchError
Since a bare variable on the left of = is bound, not compared, Elixir needs the pin operator ^ to say "use the existing value of this variable as part of the pattern." Ruby has no equivalent because it has no matching — it only ever assigns.
Atoms, Booleans & nil
Atoms are symbols
status = :ok puts status puts status.class
status = :ok IO.puts(status) IO.puts(is_atom(status))
An Elixir atom is exactly Ruby's Symbol: a constant whose name is its value, written with a leading colon. Atoms are used constantly in Elixir — as map keys, as status tags like :ok and :error, and as the names of modules and functions.
true, false and nil are atoms
puts true.class # TrueClass puts nil.class # NilClass
IO.puts(is_atom(true)) # true IO.puts(is_atom(nil)) # true IO.puts(true == :true) # true
Where Ruby has dedicated TrueClass, FalseClass, and NilClass, Elixir's true, false, and nil are simply the atoms :true, :false, and :nil with nicer spelling. There are no boolean or nil objects — just atoms all the way down.
Only false and nil are falsy
# In Ruby, only false and nil are falsy too — # 0 and "" are truthy: puts "zero is truthy" if 0 puts "empty is truthy" if ""
# Elixir agrees exactly: only false and nil are falsy. if 0, do: IO.puts("zero is truthy") if "", do: IO.puts("empty is truthy")
Here Ruby and Elixir agree completely, and it is worth stating because it trips up newcomers from other languages: only false and nil are falsy. The number 0, the empty string, and the empty list are all truthy. Elixir adds strict boolean operators (and, or, not) that require actual booleans, alongside the Ruby-like &&, ||, and ! that work on any value.
Numbers
Integer division and remainder
puts 17 / 5 # 3 (integer division) puts 17.fdiv(5) # 3.4 puts 17 % 5 # 2
IO.puts(div(17, 5)) # 3 IO.puts(17 / 5) # 3.4 (/ is always float) IO.puts(rem(17, 5)) # 2
A sharp difference: in Elixir the / operator always returns a float, even for two integers, so 17 / 5 is 3.4. Integer division is the named function div/2, and the remainder is rem/2 rather than Ruby's %. This removes the "is this / integer or float?" ambiguity that Ruby resolves by operand type.
Underscores and big integers
population = 8_100_000_000 puts population puts 2 ** 64
population = 8_100_000_000 IO.puts(population) IO.puts(2 ** 64) # arbitrary precision, like Ruby
Both languages let you group digits with underscores for readability and both have arbitrary-precision integers, so 2 ** 64 is computed exactly with no overflow. Elixir borrowed the _ digit separator directly from Ruby, and its ** power operator behaves the same.
Parsing and formatting
n = Integer("42") puts n + 1 puts 255.to_s(16) # "ff"
{n, _rest} = Integer.parse("42") IO.puts(n + 1) IO.puts(Integer.to_string(255, 16)) # "FF"
Elixir's Integer.parse/1 returns a {number, leftover_string} tuple rather than raising on trailing junk — a recurring Elixir theme of returning data you can match on instead of raising. Integer.to_string/2 takes a base, mirroring Ruby's to_s(16).
Strings & Interpolation
String interpolation
name = "Ada" puts "Hello, #{name}!"
name = "Ada" IO.puts("Hello, #{name}!")
Elixir copied Ruby's #{} interpolation syntax verbatim — one of the most visible signs of its Ruby heritage. Under the hood the two are very different: an Elixir string is a UTF-8 binary (a sequence of bytes), not an object with methods.
Concatenation with <>
greeting = "Hello, " + "World" puts greeting
greeting = "Hello, " <> "World" IO.puts(greeting)
Strings are joined with <> (the binary-concatenation operator), not +. Because + in Elixir is strictly numeric, using it on strings raises an ArithmeticError — the language refuses to guess whether you meant to add or to join.
The String module
text = " héllo " puts text.strip.upcase puts text.strip.length
text = " héllo " IO.puts(text |> String.trim() |> String.upcase()) IO.puts(String.length(String.trim(text)))
Ruby calls methods on the string object; Elixir passes the string to functions in the String module. Note that String.length/1 counts graphemes, so the accented "héllo" is length 5 — Elixir is Unicode-correct by default, just like modern Ruby.
Splitting and joining
words = "a,b,c".split(",") p words puts words.join(" | ")
words = String.split("a,b,c", ",") IO.inspect(words) IO.puts(Enum.join(words, " | "))
Splitting lives in the String module, but joining lives in Enum — because once you have split a string you hold a plain list, and joining is a list operation. This split of responsibilities (string functions vs. collection functions) is characteristic of Elixir's design.
Lists & Tuples
Lists are linked lists
numbers = [1, 2, 3] puts numbers.first puts numbers[0]
numbers = [1, 2, 3] [head | tail] = numbers IO.puts(head) # 1 IO.inspect(tail) # [2, 3]
A Ruby Array is a contiguous, index-addressable buffer; an Elixir list is a singly-linked list. That is why the idiomatic way to take it apart is the [head | tail] pattern rather than numbers[0] — reaching the head is instant, but indexing into the middle means walking the links.
Prepend, don't append
numbers = [2, 3] numbers.unshift(1) # [1, 2, 3] p numbers
numbers = [2, 3] faster = [1 | numbers] # prepend is O(1) IO.inspect(faster) # [1, 2, 3]
Because lists are linked, prepending with [element | list] is instant and cheap, while appending must copy the whole list. Elixir programs are written to grow lists at the front and reverse once at the end — the opposite of the "push onto the end" habit a Rubyist brings.
Tuples for fixed shapes
# Ruby uses arrays for everything: result = [:ok, 42] puts result[0] puts result[1]
# Tuples hold a fixed number of elements: result = {:ok, 42} IO.puts(elem(result, 0)) # ok IO.puts(elem(result, 1)) # 42
Elixir distinguishes lists (variable length, walked front-to-back) from tuples (fixed length, stored contiguously and indexed in constant time with elem/2). The convention is: tuples for a known handful of related values — especially the ubiquitous {:ok, value} and {:error, reason} — and lists for sequences you iterate.
Maps & Keyword Lists
Maps are hashes
scores = { "ada" => 10, "alan" => 8 } puts scores["ada"]
scores = %{"ada" => 10, "alan" => 8} IO.puts(scores["ada"])
An Elixir map is Ruby's Hash: a key–value store written with %{} and the => rocket that Ruby also uses. Any value can be a key, and lookup with map[key] returns nil for a missing key, exactly as in Ruby.
Atom-keyed maps and dot access
person = { name: "Ada", born: 1815 } puts person[:name] # Ruby has no person.name for hashes
person = %{name: "Ada", born: 1815} IO.puts(person.name) # dot access! IO.puts(person[:name]) # also works
When every key is an atom, Elixir offers the shorthand %{name: "Ada"} (the same key: sugar Ruby uses for symbol keys) and a special power Ruby lacks: person.name dot-access. The dot form raises if the key is missing, making it a good fit for structs where the shape is known.
Updating a map returns a new map
person = { name: "Ada", age: 36 } older = person.merge(age: 37) p person # unchanged p older
person = %{name: "Ada", age: 36} older = %{person | age: 37} IO.inspect(person) # unchanged IO.inspect(older)
The %{map | key: value} syntax returns a new map with an updated key — and it will only update keys that already exist, raising otherwise, which catches typos. Since maps are immutable, the original person is untouched; there is no in-place []= the way Ruby hashes allow.
Keyword lists are Ruby's trailing options hash
def draw(shape, color: "black", width: 1) "#{shape} in #{color}, width #{width}" end puts draw("circle", color: "red")
defmodule Canvas do def draw(shape, opts \\ []) do color = Keyword.get(opts, :color, "black") width = Keyword.get(opts, :width, 1) "#{shape} in #{color}, width #{width}" end end IO.puts(Canvas.draw("circle", color: "red"))
A keyword list — a list of {atom, value} tuples with sugar so you can write color: "red" — is Elixir's version of Ruby's trailing options hash. Passed as the last argument, the brackets are optional, so draw("circle", color: "red") reads just like Ruby keyword arguments while being an ordinary list underneath.
The Pipe & Enum
The pipe operator
result = [1, 2, 3, 4, 5] .select { |n| n.even? } .map { |n| n * 10 } .sum puts result
result = [1, 2, 3, 4, 5] |> Enum.filter(fn number -> rem(number, 2) == 0 end) |> Enum.map(fn number -> number * 10 end) |> Enum.sum() IO.puts(result)
Ruby chains methods on an object; Elixir has no objects, so the pipe |> threads a value through free functions, feeding it as the first argument to each. x |> f(y) is exactly f(x, y). The result reads like Ruby's method chain and is the single most beloved feature of the language.
reduce / inject
total = [1, 2, 3, 4].inject(0) { |sum, n| sum + n } puts total
total = Enum.reduce([1, 2, 3, 4], 0, fn number, sum -> sum + number end) IO.puts(total)
Elixir's Enum.reduce/3 is Ruby's inject/reduce, with one thing to watch: the accumulator is the second parameter of the function (fn element, acc), the reverse of Ruby's { |sum, n| } order. Nearly every other Enum function — map, filter, each, find — matches Ruby closely.
Comprehensions
squares = (1..5).select(&:even?).map { |n| n * n } p squares
squares = for number <- 1..5, rem(number, 2) == 0, do: number * number IO.inspect(squares)
The for comprehension combines mapping and filtering in one expression: number <- 1..5 is the generator, rem(number, 2) == 0 is a filter, and do: is the transformation. Ruby has no comprehension syntax, so this compact form — familiar from Python and Haskell — is a small delight for the Elixir side.
group_by
words = ["ant", "bee", "cat", "art"] by_letter = words.group_by { |w| w[0] } p by_letter
words = ["ant", "bee", "cat", "art"] by_letter = Enum.group_by(words, fn word -> String.first(word) end) IO.inspect(by_letter)
The Enum module is as rich as Ruby's Enumerable: group_by, chunk_every, frequencies, zip, flat_map, and dozens more are all present with familiar names. A Rubyist's collection instincts transfer almost one-to-one.
Pattern Matching & Control Flow
case is pattern matching
result = [:ok, 200] case result in [:ok, code] then puts "ok: #{code}" in [:error, code] then puts "err: #{code}" end
result = {:ok, 200} case result do {:ok, code} -> IO.puts("ok: #{code}") {:error, code} -> IO.puts("err: #{code}") end
Elixir's case matches a value against a series of patterns — precisely what Ruby 3's case/in pattern matching does, an idea Ruby borrowed back from languages like Elixir. Each clause both tests the shape and binds variables (code) from within it.
Guards
def sign(n) if n > 0 then "positive" elsif n < 0 then "negative" else "zero" end end puts sign(-3)
defmodule Number do def sign(n) when n > 0, do: "positive" def sign(n) when n < 0, do: "negative" def sign(_n), do: "zero" end IO.puts(Number.sign(-3))
A guard (when n > 0) adds a boolean test to a pattern. Combined with multiple function clauses, guards replace most if/elsif ladders: Elixir picks the first clause whose pattern and guard both hold. This is the language's signature style — dispatch by shape and condition, not by branching inside one body.
cond for arbitrary conditions
score = 82 grade = if score >= 90 then "A" elsif score >= 80 then "B" else "C" end puts grade
score = 82 grade = cond do score >= 90 -> "A" score >= 80 -> "B" true -> "C" end IO.puts(grade)
When the branches are arbitrary boolean expressions rather than patterns, cond is the tool — it is Elixir's if/elsif/else ladder. The final true -> clause is the catch-all else; without it, a cond that matches nothing raises.
with: a happy-path pipeline
# Ruby leans on exceptions or nested ifs: def total(a, b) return :error unless a && b a + b end puts total(10, 32)
result = with {:ok, a} <- {:ok, 10}, {:ok, b} <- {:ok, 32} do a + b else _ -> :error end IO.inspect(result)
The with expression chains several matches, running the do block only if every step matches; the first failing match short-circuits to the else. It is the idiomatic replacement for deeply nested cases or Ruby's early-return guards, and it keeps the happy path flat and readable.
Functions
Anonymous functions and the dot call
double = ->(n) { n * 2 } puts double.call(21) puts double.(21)
double = fn number -> number * 2 end IO.puts(double.(21))
An anonymous function is written fn args -> body end and — crucially — must be invoked with a dot: double.(21). The dot is Elixir's visible line between calling a named function (foo(x)) and calling a value that holds a function (foo.(x)). Ruby's lambda.(x) shorthand is the same idea.
The capture operator &
numbers = [1, 2, 3] p numbers.map(&:to_s) p numbers.map { |n| n * 2 }
numbers = [1, 2, 3] IO.inspect(Enum.map(numbers, &Integer.to_string/1)) IO.inspect(Enum.map(numbers, &(&1 * 2)))
The capture operator & is a terse way to build a function: &Integer.to_string/1 captures an existing named function (like Ruby's &:to_s), while &(&1 * 2) builds a new one where &1 is the first argument. It is the compact cousin of fn, used heavily in pipelines.
Default arguments
def greet(name, greeting = "Hello") "#{greeting}, #{name}!" end puts greet("Ada") puts greet("Ada", "Hi")
defmodule Greeter do def greet(name, greeting \\ "Hello") do "#{greeting}, #{name}!" end end IO.puts(Greeter.greet("Ada")) IO.puts(Greeter.greet("Ada", "Hi"))
Default argument values use the \\ operator (greeting \\ "Hello") instead of Ruby's =. The behavior is identical — omit the argument and the default fills in — but the different syntax exists because = is already spoken for as the match operator.
Multiple function clauses
def fib(n) return n if n < 2 fib(n - 1) + fib(n - 2) end puts fib(10)
defmodule Fib do def fib(0), do: 0 def fib(1), do: 1 def fib(n), do: fib(n - 1) + fib(n - 2) end IO.puts(Fib.fib(10))
Rather than branching inside one body, Elixir lets you write a function as several clauses, each with its own pattern; the runtime tries them top to bottom. Defining fib(0) and fib(1) as their own clauses expresses the base cases as data patterns — a style that reads like a mathematical definition.
Modules
Modules hold functions
module Calculator def self.add(a, b) = a + b def self.square(n) = n * n end puts Calculator.add(2, 3)
defmodule Calculator do def add(a, b), do: a + b def square(n), do: n * n end IO.puts(Calculator.add(2, 3))
A defmodule is a namespace for functions — closest to a Ruby module full of module-methods. Every function must live in a module (there are no free-floating top-level defs), and def makes it public while defp makes it private. There is no self. because there is no instance to belong to.
alias and import
# Ruby: include a module to mix in its methods module Greetings def hello(name) = "Hi #{name}" end class Robot include Greetings end puts Robot.new.hello("Ada")
defmodule Strings.Helper do def shout(text), do: String.upcase(text) <> "!" end import Strings.Helper IO.puts(shout("ada"))
Nested module names use dots (Strings.Helper). alias shortens a long name, and import pulls a module's functions into scope so you can call shout(...) unqualified — roughly Ruby's include, but for plain functions rather than mixed-in instance methods.
Module attributes as constants
class Circle PI = 3.14159 def self.area(r) = PI * r * r end puts Circle.area(2)
defmodule Circle do @pi 3.14159 def area(r), do: @pi * r * r end IO.puts(Circle.area(2))
A module attribute like @pi defined at the module level acts as a compile-time constant, the rough equivalent of a Ruby PI = ... constant. Attributes are also how Elixir attaches documentation (@doc), behaviour declarations, and other metadata to a module.
Structs: Data Without Objects
Structs are typed maps
class User attr_reader :name, :admin def initialize(name, admin: false) @name = name @admin = admin end end u = User.new("Ada") puts u.name
defmodule User do defstruct name: nil, admin: false end user = %User{name: "Ada"} IO.puts(user.name) # Ada IO.puts(user.admin) # false
A struct is a map with a fixed set of fields and default values, tagged with its module name. It is the closest thing Elixir has to a Ruby object's state — but it is only data: there are no methods inside it, no initialize, and no behavior. Fields are accessed with dot syntax and missing fields are a compile-time error.
Behavior lives in functions, not the struct
class BankAccount attr_reader :balance def initialize(balance) = @balance = balance def deposit(amount) @balance += amount # mutates self self end end account = BankAccount.new(100) account.deposit(50) puts account.balance
defmodule BankAccount do defstruct balance: 0 def deposit(account, amount) do %BankAccount{account | balance: account.balance + amount} end end account = %BankAccount{balance: 100} account = BankAccount.deposit(account, 50) IO.puts(account.balance) # 150
This is the heart of "no objects." Ruby bundles state and behavior and lets deposit mutate self. Elixir keeps them apart: deposit/2 is a function that takes an account and returns a new one. You rebind account to the result — the old value is never changed. Every "method" becomes a function whose first argument is the data it acts on.
Pattern matching on structs
def describe(user) if user.admin "#{user.name} (admin)" else user.name end end User = Struct.new(:name, :admin) puts describe(User.new("Ada", true))
defmodule User do defstruct name: nil, admin: false end defmodule Describe do def describe(%User{admin: true, name: name}), do: "#{name} (admin)" def describe(%User{name: name}), do: name end IO.puts(Describe.describe(%User{name: "Ada", admin: true}))
Because a struct is data, you can pattern-match on it in a function head: %User{admin: true} matches only admin users. This replaces the if user.admin check with dispatch on the data's shape — the same technique used for tuples and maps, now applied to your own types.
Protocols & Polymorphism
Teaching a struct to print itself
class Money def initialize(cents) = @cents = cents def to_s = format("$%.2f", @cents / 100.0) end puts Money.new(1599)
defmodule Money do defstruct cents: 0 end defimpl String.Chars, for: Money do def to_string(money) do dollars = money.cents / 100 "$#{:erlang.float_to_binary(dollars, decimals: 2)}" end end IO.puts(%Money{cents: 1599}) # $15.99
Ruby's to_s lets any object define how it stringifies. Elixir's equivalent is implementing a protocol: defimpl String.Chars, for: Money teaches IO.puts and interpolation how to render a Money. Protocols are Elixir's answer to "add behavior to a type you did or didn't define" — polymorphism without reopening a class.
Protocols vs. monkey-patching
# Ruby: reopen the class and add a method to EVERY instance class Array def second = self[1] end puts [10, 20, 30].second # 20
# Elixir: define a protocol, then implement it per type. # (Shown for reference — user-defined protocols need # compile-time consolidation, so this is not run here.) defprotocol Ordinal do def second(collection) end defimpl Ordinal, for: List do def second([_first, second | _rest]), do: second end Ordinal.second([10, 20, 30]) # 20
Where a Rubyist reaches for monkey-patching — reopening Array to add second globally — Elixir uses a defprotocol plus one defimpl per type. The difference is discipline: a protocol adds behavior explicitly, per type, and never silently changes a class out from under other code. (This example is not executed because user-defined protocols require a compile-time consolidation step the in-browser evaluator does not run; the built-in String.Chars example above does run.)
Error Handling
Errors as return values
def fetch(map, key) raise KeyError, key.to_s unless map.key?(key) map[key] end begin puts fetch({ a: 1 }, :b) rescue KeyError => e puts "missing: #{e.message}" end
defmodule Store do def fetch(map, key) do case Map.fetch(map, key) do {:ok, value} -> {:ok, value} :error -> {:error, "missing: #{key}"} end end end case Store.fetch(%{a: 1}, :b) do {:ok, value} -> IO.puts(value) {:error, reason} -> IO.puts(reason) end
The idiomatic Elixir style is not to raise for expected failures but to return a tagged tuple — {:ok, value} or {:error, reason} — that the caller matches on. Reserved words like Map.fetch/2 return exactly these. Errors become ordinary data you handle with case, rather than exceptions that unwind the stack.
The bang convention
# Ruby's ! means "dangerous / mutating" (save vs save!) config = { port: 4000 } port = config.fetch(:port) # raises if missing puts port
config = %{port: 4000} port = Map.fetch!(config, :port) # raises if missing IO.puts(port) case Map.fetch(config, :host) do # returns :error instead {:ok, host} -> IO.puts(host) :error -> IO.puts("no host set") end
Elixir borrows Ruby's ! naming convention but gives it a precise meaning: a foo! function is the variant that raises on failure, while the bang-less foo returns {:ok, _}/:error or nil. So Map.fetch! raises, Map.fetch returns a tuple — you pick based on whether a missing value is a bug or an expected case.
raise and rescue still exist
begin raise ArgumentError, "bad input" rescue ArgumentError => e puts "caught: #{e.message}" end
try do raise ArgumentError, "bad input" rescue e in ArgumentError -> IO.puts("caught: #{e.message}") end
Real exceptions exist for truly exceptional situations, and try/rescue mirrors Ruby's begin/rescue closely, right down to matching on the exception type and reading e.message. But in Elixir this is the rare path — most code prefers tagged tuples, and processes that crash are restarted by supervisors rather than wrapped in defensive rescue blocks.
Processes & The Actor Model
Processes talk by messages
# Ruby threads share memory and need locks: result = nil thread = Thread.new { result = 6 * 7 } thread.join puts result
parent = self() spawn(fn -> send(parent, {:result, 6 * 7}) end) receive do {:result, value} -> IO.puts("worker computed #{value}") end
This is why Elixir exists. A process is not an OS thread — it is a lightweight, VM-scheduled unit (millions can run at once) that shares no memory with any other. Processes communicate only by sending immutable messages that the recipient pulls from its mailbox with receive. No shared state means no locks, no races — the actor model, built into the language.
A process is stateful, but the data is not
# A Ruby object holds mutable state in @ivars: class Counter def initialize = @count = 0 def bump = @count += 1 def value = @count end counter = Counter.new counter.bump counter.bump puts counter.value
defmodule Counter do def loop(count) do receive do {:bump, _} -> loop(count + 1) {:value, from} -> send(from, count); loop(count) end end end counter = spawn(fn -> Counter.loop(0) end) send(counter, {:bump, nil}) send(counter, {:bump, nil}) send(counter, {:value, self()}) receive do total -> IO.puts(total) end
How can an immutable language have stateful counters? A process holds its state as an argument to a recursive loop: to "change" the count, it calls loop/1 again with a new value. The state lives in the process's own stack, reachable only through messages — so state exists, but no shared mutable memory does. This hand-rolled loop is exactly what GenServer and Agent package up for real code.
Every process has an address
# Ruby: Thread.current identifies the running thread puts Thread.current.class
pid = self() IO.puts(is_pid(pid)) # true child = spawn(fn -> :ok end) IO.puts(is_pid(child)) # true
Every process is identified by a pid (process identifier). self() returns the current process's pid, and spawn/1 returns the new child's. A pid is the address you send messages to — and because a pid can point at a process on another machine just as easily as this one, the same message-passing code scales from one core to a distributed cluster.
Gotchas for Rubyists
You cannot mutate in a loop
total = 0 [1, 2, 3].each { |n| total += n } # mutates total puts total
# This does NOT work — the closure gets its own copy, # and the outer total is never changed: total = 0 Enum.each([1, 2, 3], fn number -> total = total + number end) IO.puts(total) # 0, not 6! # Use reduce to thread an accumulator instead: IO.puts(Enum.reduce([1, 2, 3], 0, &+/2)) # 6
The single most common Rubyist mistake: reassigning an outer variable from inside a loop. In Elixir the closure rebinds its own total, and the outer one is untouched — so the loop appears to do nothing. Because nothing is mutable, you must reduce to carry an accumulator through, or build a new collection with map. There is no such thing as "modify as you go."
Single quotes are not strings
a = "hello" b = 'hello' puts a == b # true in Ruby
a = "hello" # a UTF-8 binary (a string) b = ~c"hello" # a charlist: [104, 101, 108, 108, 111] IO.puts(is_binary(a)) # true IO.inspect(b) # ~c"hello" IO.puts(a == to_string(b)) # true, once converted
In Ruby single and double quotes both make strings. In Elixir a double-quoted "hello" is a binary string, but a ~c"hello" charlist is a plain list of integer code points — a different type entirely, occasionally seen in Erlang libraries. Mixing them up is a classic early confusion; when in doubt, use double quotes.
Atoms are never garbage-collected
# Ruby symbols are GC'd since 2.2, so this is fine: 1000.times { |i| "sym_#{i}".to_sym } puts "created and forgotten"
# Safe: a fixed set of atoms written in source. statuses = [:ok, :error, :pending] IO.inspect(statuses) # DANGER (do not do this): never call # String.to_atom(user_input) # in a loop — atoms live forever and can exhaust the table.
Unlike modern Ruby symbols, Elixir atoms are never garbage-collected — they live for the life of the VM. Atoms written literally in your code are finite and safe, but converting untrusted input to atoms with String.to_atom/1 in a loop can exhaust the atom table and crash the node. Use String.to_existing_atom/1, or keep the data as strings.
There is no method chaining on values
puts "hello world".split.map(&:capitalize).join(" ")
# No value has methods, so you cannot chain on the string. # Pipe through module functions instead: result = "hello world" |> String.split() |> Enum.map(&String.capitalize/1) |> Enum.join(" ") IO.puts(result)
A string, list, or map has no methods of its own — so "hello world".split is meaningless in Elixir. The pipe recovers the readability of Ruby's chains by threading the value through String and Enum functions. Once this clicks, the "data plus functions" model stops feeling like a limitation and starts feeling like clarity.