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.