Elixir is a dynamic, functional language built for creating scalable and fault-tolerant applications. It runs on the Erlang Virtual Machine BEAM and is designed to leverage its strengths in concurrency, distribution, and fault tolerance.
Elixir code is compiled to BEAM bytecode - the same format used by Erlang. These compiled files have the .beam extension and are executed by the BEAM virtual machine.
No. Elixir and Erlang run on the exact same runtime, the BEAM. Elixir is not a reimplementation of Erlang - it’s a different language that targets the same VM.
You can call Erlang functions from Elixir and vice versa, import Erlang standard libraries, and even include .erl files in Elixir projects.
# Calling the Erlang math module from Elixir
:math.sqrt(9) # => 3.0
In practice, Elixir performs just as well as Erlang, since both compile to the same BEAM bytecode. Performance differences are negligible, and mostly depend on how you write your code - not the language itself.
Elixir runs on the BEAM - the same virtual machine that powers Erlang systems, many of which have been running 24/7 for decades (e.g. telecom switches, banking, messaging platforms).
The BEAM was designed from the ground up to support five key pillars:
Erlang (base language of Elixir) processes are completly isolated from each other. If one crashes, it doesn’t affect the rest. Supervision trees let the system recover gracefully - let it crash is a feature, not a bug.
The VM uses preemptive scheduling to ensure that no process can block others. Even under heavy load, the system stays responsive.
BEAM has built-in support for connecting multiple nodes across machines. Processes can send messages to each other - locally or over the network - without extra libraries or complexity.
Because BEAM processes are lightweight and isolated, you can run hundreds of thousands (even millions) of them efficiently, across multiple cores or machines.
The BEAM supports live updates. You can deploy changes without restarting the system, which is critical for telecom and banking environments. You wouldn’t want to disconnect established calls while you upgrade the software.
In Elixir, data is always immutable.
If you want to “change” something, you’re really just creating a new version of it. That might seem wasteful at first, but it actually works really well with how Elixir handles memory.
Every process has its own little memory space and its own garbage collector, so cleaning up temporary data is fast and doesn’t slow down the rest of the system. Thanks to this setup, plus super lightweight processes, you get safe, predictable concurrency without eating up tons of memory. Even when your app is doing a million things at once.
One of the fundamental principles of Elixir (and functional programming in general) is that everything returns a value.
There are no void functions or statements that exist purely for side effects. Whether you’re using a control structure like if, a function like String.upcase/1, or a custom pipeline of transformations, each operation produces a result.
This design makes function composition not just possible, but natural - because you’re always working with return values.
For instance:
result =
if true do
"yes"
else
"no"
end
IO.puts(result)
# => prints: yes
Even if is an expression. It evaluates to a value based on the condition, which can be passed along, assigned, or returned.
This consistency makes Elixir highly chainable, composable, and predictable - ideal for building data pipelines and transforming values step by step.
Elixir provides an elegant way to compose functions using the pipe operator: |>. It allows you to chaing multiple functions calls together in a clear and readeble way - passing the result of one function as the first argument to the next.
" hello world "
|> String.trim() # removes the leading and trailing spaces → "hello world"
|> String.upcase() # converts it to "HELLO WORLD
|> String.split() # splits it into ["HELLO", "WORLD"]Without pipe:
String.split(String.upcase(String.trim(" hello world ")))In Elixir, arity refers to the number of arguments a function takes. It’s an important concept because functions with the same name but different arity are treated as completely separate functions.
defmodule Greet do
def hello(), do: "Hello!"
def hello(name), do: "Hello, #{name}!"
end
In this case, hello/0 and hello/1 are two different functions:
hello/0 takes no argumentshello/1 takes oneIn Elixir, you can write simple one-liner functions using the shorthand do: syntax. It is clean and great for small, focused logic.
defmodule Math do
def square(x), do: x * x
def double(x), do: x * 2
end
It’s functionally the same as using do ... end, just shorter - perfect when the body is a single expression.
Also, you can reference a function using &module.function/arity, which gives you an anonymous version of that function.
adder = &Math.add/2
adder.(2, 3) # => 5You can also use the & shorthand to create anonymous functions with placeholders like &1, &2, and so on.
Enum.map([1, 2, 3], &(&1 * 2)) # => [2, 4, 6]In Elixir, a module is just a way to group related functions together under a single name. It helps keep your code organized and makes it easy to reuse logic.
You define a module using defmodule, and then write your functions inside it with def.
defmodule User do
# Define a struct with default fields
defstruct [:name, :age]
# Public function to create a new user from string-keyed mapW
def new(attrs) do
attrs
|> normalize_keys()
|> build_struct()
end
# Public function to check if user is an adult
def adult?(%User{age: age}) do
age >= 18
end
# Private helper to clean up external input
defp normalize_keys(%{"name" => name, "age" => age}) do
%{name: name, age: age}
end
# Private helper to turn a map into a User struct
defp build_struct(attrs) do
struct(**MODULE**, attrs)
end
# You can nest module definitions
defmodule Formatter do
def greeting(%User{name: name}) do
"Hello, #{name}!"
end
def label(%User{name: name, age: age}) do
"#{name} (#{age} years old)"
end
end
end
# Usage:
user = User.new(%{"name" => "Alice", "age" => 25})
User.adult?(user) # => true
User.Formatter.greeting(user) # => "Hello, Alice!"
User.Formatter.label(user) # => "Alice (25 years old)"
The latter is often used to organize modules hierarchically:
defmodule Bmt.Analytics.InteractionEvent do
# Code...
end
defmodule Bmt.Analytics.EventLog do
# Code...
endYou can’t split a module across files - it has to live in one. But you’re free to define more than one module in the same file.
Numbers can be integers or floats. Numbers work mostly as you expected, so I’m gonna focus only on some untypical:
4/2 => 2.0In Elixir, atoms are constants whose name is their own value. They’re often used to represent fixed, named values like flags, tags, or states - similar to enums or symbols in other languages.
Elixir doesn’t have a dedicated boolean type. Instead, the atos :true and :false are used. You can also, write these atoms, not using colon character, like :true == true, :false == false.
The same for nil == nil atom.
Tuples are intended as fixed-size containers for multiple elements.
Note: the function put_elem doesn’t modify the tuple. It returns the new version, keeping th old one unchanged. Remember that the data in Elixir is immutable.
Lists in Erlang are used to handle collections of data where the size can change dynamically.
Lists may look like arrays at first glance, but they behave like singly linked lists. To process or access elements, the list must be traversed from the beginning. As a result, most list operations have a time complexity of .
Lists are never a good fit when direct access is called for.
In Elixir and Erlang, lists are built as recursive structures, where each list is made up of a head (the first element) and a tail (the rest of the list). This makes them easy to traverse recursively, which is a common pattern in functional programming. Because the list is essentially a chain of cons cells, accessing the head hd/1 or tl/1 are fast - .
It’s much faster to add items to the beginning of a list than to the end. That’s because lists are built like chains - each item points to the next. Adding to the front is instant , but adding to the end means walking through the whole list . So it’s common to build lists by prepending and then reverse them if needed.
Maps are key-value data structures used to store and access data efficiently. Keys can be any type, but atoms are most common. Maps are great for representing structured data, like user profiles or settings.
# Creating a map
user = %{name: "Alice", age: 30}
# Accessing values
user.name # => "Alice"
user[:age] # => 30
# Updating values (returns a new map)
updated_user = %{user | age: 31}
# Adding a new key
new_user = Map.put(user, :email, "alice@example.com")
Maps are immutable, so updates return a new version of the map, not an in-place change. They are very fast for lookups and are one of the most commonly used data types in Elixir.
In Elixir, you can create anonymous functions using the fn -> ... end syntax and assign them to variables. This allows you to treat functions like values - you can store them, pass them around, or call them later. To invoke an anonymous function, you use a dot: f.(arg). This makes it easy to write flexible, reusable logic without naming the function globally.
greet = fn name -> "Hello, #{name}!" end
greet.("Alice") # => "Hello, Alice!"This pattern is especially useful for short callbacks, Enum operations, or when you want to keep logic scoped and lightweight.
Range is a simple abstraction that represents a sequence of numbers with a start and end. You can create one using the .. syntax, like 1..5. Ranges are lazy and don’t store all elements in memory - they’re just a start and end value, which makes them efficient for iteration. They work great with Enum, for, and pattern matching.
MapSet is the implementation of a set - a store of unique values, where a value can be of any type.
Sigils are a special syntax used to work with text, regexes, lists, and more in a concise way. They start with a ~ followed by a letter that indicates the type, like ~s for strings or ~r for regular expressions. Sigils make it easier to write certain kinds of data without escaping characters or using long syntax.
~s(Hello\nWorld) # => "Hello\nWorld" (interprets escapes)
~S(Hello\nWorld) # => "Hello\\nWorld" (literal, no escape)
~r/hello/ # => a regex pattern
~w(one two three) # => ["one", "two", "three"]
In Elixir, variables are immutable, but they can be rebound - meaning you can assign a new value to the same variable name in a new context.
You’re not changing the existing value; you’re simply creating a new binding for that name. This works because variable names in Elixir are just labels for values, and they can point to something else in a new line or scope.
x = 1
x = x + 1 # x is now 2, but the original 1 wasn't mutatedRebinding feels flexible, but it’s still safe and functional - there’s no mutation happening under the hood.
And since old values are no longer referenced, Elixir’s per-process garbage collector will clean them up efficiently, keeping memory usage low and predictable even in long-running systems.
In Elixir, the = operator is not about assigning a value - it’s a pattern matcher. It tries to match the structure and shape of the value on the right-hand side with the pattern on the left. If the pattern matches, values are bound to the corresponding variables. If not, Elixir raises a MatchError.
Pattern matching works with tuples, lists, maps, structs, function arguments, case expressions, and more - and it’s one of the most expressive features in the language.
{a, b} = {1, 2}
# a = 1, b = 2
%{name: name} = %{name: "Alice"}
# name = "Alice"
[head | tail] = [1, 2, 3]
# head = 1, tail = [2, 3]Pattern matching lets you deconstruct data directly, instead of manually accessing fields or indices. It also powers function overloading - you can define multiple versions of a function that match different patterns:
def handle_response({:ok, data}), do: "Success: #{data}"
def handle_response({:error, reason}), do: "Error: #{reason}"This makes Elixir code concise, expressive, and often branch-free, since much of your control flow happens through matching patterns instead of using if or switch.
Variables are usually rebound when used in pattern matching - meaning they get a new value if the pattern matches. But sometimes, you want to match against the current value of a variable instead of rebinding it. That’s where the ^ operator (called the pin operator) comes in.
It “pins” the variable to its existing value, so pattern matching only succeeds if the value matches exactly
x = 5
case 5 do
^x -> "Matched!" # uses the value of x (5)
\_ -> "No match"
end
Without the ^, Elixir would treat x as a new variable and try to bind a new value to it. With the ^, you’re saying: “this must match the current value of x”.
This is especially useful in case, with, and function clauses when you want to enforce an exact match.
Guards are additional conditions you can add to pattern matches to make them more precise. They let you run simple checks like comparisons, type checks, or math. Guards use the when keyword.
def check_age(age) when age >= 18, do: "Adult"
def check_age(_), do: "Minor"
Only certain functions (called guard-safe) are allowed in guards, like is_integer/1, length/1, >/2, etc.
In Elixir, iteration is often done using recursion, since there are no traditional loop constructs like for or while. To keep recursive code efficient, Elixir supports tail call optimization - but only when the function is written in a tail-recursive way.
A function is tail-recursive when the recursive call is the very last thing the function does. This allows the VM to reuse the current stack frame, meaning it doesn’t have to keep a growing stack of calls in memory. As a result, tail-recursive functions can safely handle very large lists without blowing the stack.
Tail-recursive version:
defmodule Sum do
def total([], acc), do: acc
def total([head | tail], acc), do: total(tail, acc + head)
end
Sum.total([1, 2, 3], 0) # => 6
Here, the recursive call to total/2 is the final action, so the VM can optimize it.
Non-tail-recursive version:
defmodule Sum do
def total([]), do: 0
def total([head | tail]), do: head + total(tail)
endIn this version, total(tail) is called, but then its result is added to head, meaning the recursive call isn’t the final action. The result is not tail-recursive, and each call must wait for the next one to finish - this builds up a call stack.
Both tail and non-tail recursive functions usually have time complexity, since each element must be visited once. But the memory usage is very different:
When writing recursive functions in Elixir:
In the BEAM, the core unit of concurrency is the process - a lightweight, isolated building block that enables scalable, fault-tolerant, and distributed systems.
The fundamental building block of concurrency in Erlang (and Elixir) is the process - but not an operating system process or thread.
These are lightweight, isolated processes managed entirely by the BEAM virtual machine. They’re extremely efficient, allowing systems to run thousands or even millions of them concurrently.
Each process has:
Examples:
# Here's how you might spawn a process in Elixir that simply prints a message:
spawn(fn -> IO.puts("Hello from a new process!") end)
You can also send and receive messages:
# Start a process that waits for a message
pid = spawn(fn ->
receive do
{:ping, sender} -> send(sender, :pong)
end
end)
# Send a message and wait for the response
send(pid, {:ping, self()})
receive do
:pong -> IO.puts("Got a pong!")
end
Although the terms are often used interchangeably, concurrency and parallelism are not the same.
Concurrency means dealing with many things at once - it’s about structuring your program to handle multiple tasks without waiting.
Parallelism is about doing many things literally at the same time, using multiple CPU cores.
You can think of concurrency as a design strategy, and parallelism as a performance optimization. Elixir excels at concurrency, and thanks to the BEAM’s scheduler, it can achieve parallelism when running on multicore CPUs - but it doesn’t depend on it.
While spawning processes manually works for simple tasks, most real-world Elixir applications use GenServer, a built-in abstraction for building long-running, stateful, concurrent processes.
A GenServer handles messages, maintains internal state, and integrates seamlessly with OTP features like supervision, timeouts, and restarts.
Instead of writing raw receive blocks, you implement callback functions like handle_call/3 or handle_cast/2, and let the runtime handle the plumbing.
defmodule Counter do
use GenServer
# Client API
def start_link(initial) do
GenServer.start_link(**MODULE**, initial, name: **MODULE**)
end
def increment(), do: GenServer.cast(**MODULE**, :inc)
def get(), do: GenServer.call(**MODULE**, :get)
# Server Callbacks
def init(initial), do: {:ok, initial}
def handle_cast(:inc, state), do: {:noreply, state + 1}
def handle_call(:get, \_from, state), do: {:reply, state, state}
end
With GenServer, you don’t have to manage low-level details - the BEAM handles process lifecycle, crashes, and messaging behind the scenes. It’s the foundation for building robust, fault-tolerant systems in Elixir.