No sections found
We couldn't find anything matching your search query. Try adjusting your keywords.
Learn Modern Elixir for Python and Javascript Developers
If you are tired of debugging `undefined is not a function` in JS, fighting the Global Interpreter Lock (GIL) in Python, or managing a chaotic web of sprawling Microservices to handle background jobs and WebSockets... welcome to Elixir.
Created by José Valim, Elixir combines the gorgeous developer ergonomics of Ruby with the legendary industrial strength of the Erlang BEAM Virtual Machine. It gives you 99.9999999% uptime, true effortless concurrency, and a functional paradigm that makes your code predictable. Coupled with the Phoenix Framework and LiveView, you can build rich, real-time web applications without writing custom JavaScript state management.
2. The Rosetta Stone: Types Grid
Like Python and JS, Elixir is dynamically but strongly typed. The compiler won't let you add an integer to a string.
| Concept | Elixir | Python 3.12+ | JS (ES2026) |
|---|---|---|---|
| Variable Binding | x = 42 | x = 42 | const x = 42; |
| Atom (Symbol) | status = :ok # Used everywhere for signaling |
N/A (Strings used) | Symbol("ok") |
| String | name = "Elixir" | name = "Python" | const name = "JS"; |
| Null / None | val = nil | val = None | const val = null; |
| Tuple | res = {:ok, 200} # Fixed length, fast read |
res = ("ok", 200) | const res = ["ok", 200]; |
| List | nums = [1, 2, 3] # Singly Linked List, fast prepend |
nums = [1, 2, 3] | const nums = [1, 2, 3]; |
| Map (Dictionary) | user = %{id: 1, name: "A"} | user = {"id": 1, "name": "A"} | const user = {id: 1, name: "A"}; |
| Keyword List | opts = [timeout: 5000] # Just a list of tuples under the hood |
**kwargs | {timeout: 5000} |
3. Immutability & Variables
In Python and JS, variables are often references to objects in memory that can be mutated from anywhere, leading to nasty bugs. In Elixir, all data is immutable. When you modify a list or map, Elixir returns a new copy (sharing memory behind the scenes for efficiency).
def add_user(users):
users.append("Charlie") # MUTATES!
return users
my_users = ["Alice", "Bob"]
add_user(my_users)
# The original list is permanently changed
print(my_users)
# => ['Alice', 'Bob', 'Charlie']
function addUser(users) {
users.push("Charlie"); // MUTATES!
return users;
}
const myUsers = ["Alice", "Bob"];
addUser(myUsers);
// myUsers is changed despite 'const'
console.log(myUsers);
// => ['Alice', 'Bob', 'Charlie']
def add_user(users) do
# Lists are Linked Lists. Prepends are O(1)
# This returns a brand new list!
["Charlie" | users]
end
my_users = ["Alice", "Bob"]
new_users = add_user(my_users)
# The original list is completely safe
IO.inspect(my_users)
# => ["Alice", "Bob"]
4. Pattern Matching (The "If" Killer)
In Elixir, the = sign is not an assignment operator; it is a Match Operator. It works like algebra. `x = 1` sets x to 1. But `1 = x` is perfectly valid Elixir—it asserts that x is 1. If it's not, the program crashes. This eliminates huge if/else blocks.
function handleResponse(response) {
// Destructuring exists, but it doesn't control flow
if (response.status === 200 && response.body.user) {
return `Welcome ${response.body.user.name}`;
} else if (response.status === 404) {
return "Not found!";
} else {
return "Unknown error";
}
}
# Elixir allows multiple function definitions.
# It runs the first one where the arguments perfectly MATCH the pattern!
def handle_response(%{status: 200, body: %{user: %{name: name}}}) do
# It asserts status is 200, deeply extracts `name`, and assigns it.
"Welcome #{name}"
end
def handle_response(%{status: 404}) do
"Not found!"
end
# The catch-all (matches anything)
def handle_response(_response) do
"Unknown error"
end
5. The Pipe Operator `|>`
If there is one reason developers fall in love with Elixir, it's the Pipe Operator. It takes the output of the expression on its left, and passes it as the first argument to the function on its right. Data flows beautifully from top to bottom.
Task: Take a CSV string, split it, convert to integers, keep only even numbers, and sum them up.
data = "1,2,3,4,5,6"
# Pythonic, but reads inside-out
# and gets messy if logic is complex.
result = sum([
int(x) for x in data.split(",")
if int(x) % 2 == 0
])
const data = "1,2,3,4,5,6";
// Nice, but only works because map/filter
// are attached to the Array class object.
const result = data
.split(",")
.map(x => parseInt(x))
.filter(x => x % 2 === 0)
.reduce((acc, curr) => acc + curr, 0);
data = "1,2,3,4,5,6"
# Pure functions, no class objects required.
# Reads like a data assembly line.
result =
data
|> String.split(",")
|> Enum.map(&String.to_integer/1)
|> Enum.filter(fn x -> rem(x, 2) == 0 end)
|> Enum.sum()
6. Guess the Number Game
Because Elixir is immutable, there are no while loops. We use Recursion. The Erlang VM has Tail Call Optimization, meaning a recursive function call at the end of a block will never cause a stack overflow.
import random
def play_game():
secret = random.randint(1, 100)
print("Guess a number!")
while True:
try:
guess = int(input("> "))
if guess < secret:
print("Higher!")
elif guess > secret:
print("Lower!")
else:
print("You win!")
break
except ValueError:
print("Invalid input!")
play_game()
defmodule Game do
def play do
secret = :rand.uniform(100)
IO.puts("Guess a number!")
loop(secret)
end
def loop(secret) do
# Get input, pipe to trim, pipe to integer parse
input = IO.gets("> ") |> String.trim() |> Integer.parse()
# Integer.parse returns {integer, remainder} or :error
case input do
{guess, _} when guess < secret ->
IO.puts("Higher!")
loop(secret) # Tail recursion!
{guess, _} when guess > secret ->
IO.puts("Lower!")
loop(secret)
# The Pin operator (^) matches against the EXISTING variable
{^secret, _} ->
IO.puts("You win!")
:error ->
IO.puts("Invalid input!")
loop(secret)
end
end
end
Game.play()
7. File I/O & JSON Parsing
Reading files and parsing JSON is incredibly common. Elixir uses the Jason library (almost universally included).
const fs = require('fs');
function extractEmails() {
try {
const rawData = fs.readFileSync('users.json', 'utf8');
const users = JSON.parse(rawData);
// Map over the array to get emails
const emails = users.map(user => user.email);
console.log(emails);
} catch (error) {
console.error("Failed to read file", error);
}
}
defmodule UserProcessor do
def extract_emails do
# Notice the ! at the end of the functions.
# In Elixir, a bang (!) means "Crash if there is an error".
# Without the bang, it returns {:ok, data} or {:error, reason}.
"users.json"
|> File.read!()
|> Jason.decode!()
|> Enum.map(fn user -> user["email"] end)
|> IO.inspect()
end
end
8. Error Handling (The with block)
Exceptions (try/catch) are meant for exceptional circumstances (like a DB going down). For expected failures (like a user typing bad input, or a file missing), Elixir returns tuples. To prevent nested case statements, Elixir provides the brilliant with block.
def process_purchase(user_id, item_id):
user = fetch_user(user_id)
if not user:
return "User not found"
item = fetch_item(item_id)
if not item:
return "Item not found"
if user.balance < item.price:
return "Insufficient funds"
# Proceed to charge
return charge_user(user, item)
def process_purchase(user_id, item_id) do
# `with` executes steps in order.
# If any step DOES NOT match the left side of <-,
# the chain stops and returns the mismatched value.
with {:ok, user} <- fetch_user(user_id),
{:ok, item} <- fetch_item(item_id),
:ok <- check_balance(user, item) do
# If all matches succeed, do this:
charge_user(user, item)
else
# Pattern match on the failure points!
{:error, :user_not_found} -> "User not found"
{:error, :item_not_found} -> "Item not found"
{:error, :insufficient_funds} -> "Insufficient funds"
end
end
9. Tasks (The Async/Await Killer)
In Javascript, doing 5 API calls in parallel requires Promise.all(). In Python, it requires asyncio.gather() and managing an event loop. In Elixir, concurrency is not a library—it is baked into the VM. You just spawn a Task.
const urls = ['/api/1', '/api/2', '/api/3'];
async function fetchAll() {
// Map strings to an array of unresolved Promises
const requests = urls.map(url => fetch(url));
// Wait for all to resolve in parallel
const responses = await Promise.all(requests);
return responses;
}
urls = ["/api/1", "/api/2", "/api/3"]
def fetch_all(urls) do
urls
# Spawns a lightweight process for each URL
|> Enum.map(fn url -> Task.async(fn -> fetch(url) end) end)
# Awaits the results (blocks the calling process, but not the whole VM)
|> Enum.map(fn task -> Task.await(task) end)
# Or use the built-in shorthand:
# Task.async_stream(urls, &fetch/1) |> Enum.to_list()
end
10. Concurrency Deep Dive: The Ring Benchmark
The true power of the BEAM VM is the Actor Model. In Python, spawning 100,000 OS threads will crash your laptop. Node.js is single-threaded. Elixir "processes" are incredibly lightweight (starting at ~2KB). Elixir can spawn millions of processes on a standard laptop, spreading them perfectly across all available CPU cores. Processes communicate exclusively via Message Passing.
defmodule Ring do
@doc "Spawns N processes in a circle, and passes a message M times."
def start(n, m) do
# Build the ring backwards. self() is the main process PID.
first_pid = create_ring(n, self())
# Send the initial message tuple to the first process
send(first_pid, {:message, m})
# Block and wait for the completion signal back
receive do
:done -> IO.puts("Finished passing message #{m} times around #{n} nodes!")
end
end
defp create_ring(1, next_pid), do: spawn(fn -> process_node(next_pid) end)
defp create_ring(n, next_pid) do
current_pid = spawn(fn -> process_node(next_pid) end)
create_ring(n - 1, current_pid)
end
# Every node sits in this recursive loop, waiting for a message
defp process_node(next_pid) do
receive do
# Pattern match: If 0 trips left, we are done. Forward :done.
{:message, 0} -> send(next_pid, :done)
# Otherwise, decrement and forward the message, then loop again.
{:message, trips_left} ->
send(next_pid, {:message, trips_left - 1})
process_node(next_pid) # Tail recursion keeps memory flat!
# Forward the :done token and exit.
:done -> send(next_pid, :done)
end
end
end
# Spawns 100,000 processes instantly:
# Ring.start(100_000, 10)
11. State Management: OOP Classes vs GenServer
Because Elixir variables are immutable, how do you hold state (like an in-memory counter or a cache)? You use a process! A GenServer (Generic Server) is the standard OTP behavior that runs a continuous loop holding state, accepting synchronous calls and asynchronous casts.
# Thread-unsafe in multi-threaded environments
# without manual lock management!
class Counter:
def __init__(self, initial=0):
self.count = initial
def get_count(self):
return self.count
def increment(self):
self.count += 1
# Usage:
counter = Counter(10)
counter.increment()
print(counter.get_count())
# Runs in its own thread. Mailbox inherently queues messages,
# so it is 100% thread-safe without manual locks.
defmodule Counter do
use GenServer
# Client API
def start_link(initial) do
GenServer.start_link(__MODULE__, initial, name: :my_counter)
end
def get_count do
GenServer.call(:my_counter, :get_count) # Synchronous
end
def increment do
GenServer.cast(:my_counter, :increment) # Asynchronous
end
# Server Callbacks
@impl true
def init(initial), do: {:ok, initial}
@impl true
def handle_call(:get_count, _from, state) do
# {:reply, response, new_state}
{:reply, state, state}
end
@impl true
def handle_cast(:increment, state) do
# {:noreply, new_state}
{:noreply, state + 1}
end
end
# Usage:
# Counter.start_link(10)
# Counter.increment()
# IO.puts(Counter.get_count())
12. The Phoenix Framework
Phoenix is Elixir's premier web framework (comparable to Django, Express, or Ruby on Rails, but capable of millions of simultaneous connections). It compiles your routing and HTML templates using Macros, meaning your views evaluate at microsecond speeds.
Sub-millisecond Responses
Phoenix pages frequently render in 50-100 microseconds. Not milliseconds. Microseconds.
Ecto (Database Wrapper)
Not exactly an ORM. Ecto uses Changesets to validate data without hiding SQL logic. It enforces a strict boundary between database data and application logic.
Channels
Natively handle millions of WebSockets on a single machine. No Redis required. A broadcast on Node A reaches connected clients on Node B instantly.
13. Real-time: React SPA vs Phoenix LiveView
The industry spent the last decade shifting state to the browser (React/Vue), requiring APIs, state synchronization, and complex JS tooling. LiveView brings state back to the server. When a user clicks a button, an event is sent over a WebSocket. The server calculates the state change, computes the exact minimal DOM diff, and pushes just the diff back. You write zero custom Javascript.
// Frontend (React Component)
function Counter() {
const [count, setCount] = useState(0);
const increment = async () => {
// 1. Send HTTP request
await fetch('/api/inc', {method: 'POST'});
// 2. Update local state
setCount(count + 1);
};
return (
<div>
<h1>{count}</h1>
<button onClick={increment}>+</button>
</div>
);
}
// Backend (Express API)
app.post('/api/inc', async (req, res) => {
await db.query("UPDATE counter SET val = val + 1");
res.send({status: 'ok'});
});
defmodule MyAppWeb.CounterLive do
use MyAppWeb, :live_view
# 1. State lives here (in a lightweight process)
def mount(_params, _session, socket) do
{:ok, assign(socket, count: 0)}
end
# 2. Events triggered from HTML hit this function natively via Websocket
def handle_event("inc", _params, socket) do
# Server computes the DOM diff and pushes it to browser!
{:noreply, assign(socket, count: socket.assigns.count + 1)}
end
# 3. HTML Template (HEEx)
def render(assigns) do
~H"""
<div>
<h1><%= @count %></h1>
<!-- phx-click wires directly to handle_event -->
<button phx-click="inc">+</button>
</div>
"""
end
end
14. Testing: PyTest/Jest vs ExUnit
Elixir ships with ExUnit out of the box. No need to install Jest, Mocha, or Pytest. ExUnit runs asynchronously by default, utilizing all your CPU cores, meaning test suites finish in seconds, not minutes.
import pytest
from my_math import MathCalc
def test_addition():
calc = MathCalc()
assert calc.add(2, 3) == 5
def test_division_by_zero():
calc = MathCalc()
with pytest.raises(ZeroDivisionError):
calc.divide(10, 0)
defmodule MathCalcTest do
use ExUnit.Case, async: true # Tests run in parallel!
test "addition" do
# Just use 'assert'. ExUnit uses Macros to give
# beautiful colored diffs on failure.
assert MathCalc.add(2, 3) == 5
end
test "division by zero" do
assert_raise ArithmeticError, fn ->
MathCalc.divide(10, 0)
end
end
end
15. Tips, Tricks & Beginner Gotchas
Transitioning to functional programming takes a mindset shift. Here are the common stumbling blocks.
Gotcha: State Rebinding inside loops
You cannot alter a variable from outside a block. Doing `sum = 0; Enum.each(list, fn x -> sum = sum + x end)` will not work. The `sum` inside the function is a different scoped variable. You must use `Enum.reduce/3` to carry state through an iteration.
The Underscore (Ignore)
Elixir will warn you if you define a variable but don't use it. If you are pattern matching a tuple like {:ok, value} but only care that it was :ok, use an underscore: {:ok, _} = function_call(). If you want to document what you are ignoring, start the variable with an underscore: _reason.
Function Arity
In Elixir documentation, you will constantly see functions written like String.split/2. The /2 is the Arity—it means the function takes 2 arguments. In Elixir, split/1 and split/2 are entirely different functions!
16. Software Engineering Best Practices (Tooling)
The Elixir tooling ecosystem is renowned for its excellent developer experience. Everything is built-in.
Mix
mix is your best friend. mix new app creates a project. mix deps.get fetches dependencies. mix test runs tests. mix format perfectly styles your code. You never have to configure Webpack, Pipenv, or Prettier.
Hex & HexDocs
Hex.pm is the package manager. Unlike NPM, package documentation is standardized. When you publish a package, Elixir automatically generates gorgeous, searchable documentation and hosts it on HexDocs.pm.
IEx (Interactive Elixir)
The Elixir REPL. Run iex -S mix to start a console loaded with your entire application's code. You can interact with your database, call functions, and even connect to production servers to debug live state.
ElixirLS
The Elixir Language Server. Install the extension in VSCode for auto-completion, inline documentation on hover, and automatic Dialyzer (type checking) feedback as you type.