Elixir: Functional Programming for Concurrent Systems
Elixir is a functional, concurrent programming language built on the Erlang Virtual Machine (BEAM). Created by José Valim in 2011, Elixir combines the expressiveness of modern language design with the battle-tested concurrency and fault-tolerance primitives of the Open Telecom Platform (OTP). It powers production systems at companies like Discord, Pinterest, and Heroku, handling millions of concurrent connections with minimal resources.
This guide covers Elixir’s functional foundations, its unique concurrency model, OTP patterns, and the tools that make Elixir a compelling choice for building scalable, reliable systems. Elixir’s design philosophy, as articulated by José Valim, is to make concurrent programming accessible without sacrificing the reliability that the BEAM VM is known for.
Functional Foundations
Elixir enforces functional programming principles at the language level. Data is immutable by default — every operation returns a new value rather than modifying existing data. This eliminates entire categories of bugs related to shared mutable state and makes concurrent programming significantly safer.
list = [1, 2, 3]
new_list = [0 | list]
user = %{name: "Alice", age: 30}
updated = %{user | age: 31}Elixir’s immutability is not a burden thanks to persistent data structures that share structure between versions. The BEAM’s memory management is optimized for the pattern of creating many short-lived values, making immutability practically efficient. This is similar to how functional languages like Clojure and Scala achieve efficient immutability through structural sharing.
Variables as Bindings
In Elixir, variables are not storage locations but bindings to values. The = operator is a match operator, not an assignment operator. This distinction is fundamental to understanding Elixir:
x = 1 # bind x to 1
x = 2 # rebind x to 2 (not mutation)This means you can reuse variable names without concerning yourself with whether the old value is still being used elsewhere — each “assignment” creates a new binding to an immutable value, and the old binding is simply no longer accessible.
Pattern Matching
Pattern matching is Elixir’s most distinctive and powerful feature. It is not a separate construct but a core part of how assignment, destructuring, and control flow work. The = operator in Elixir is not assignment in the imperative sense — it is a match operator that asserts the left side matches the right side.
[head | tail] = [1, 2, 3, 4]
%{name: name, age: age} = %{name: "Alice", age: 30}
def factorial(0), do: 1
def factorial(n) when n > 0, do: n * factorial(n - 1)
def describe_age(age) when age < 13, do: "Child"
def describe_age(age) when age < 20, do: "Teenager"
def describe_age(age) when age < 65, do: "Adult"
def describe_age(_), do: "Senior"The compiler checks that your pattern matches are exhaustive. If you forget to handle a case, the compiler emits a warning — a safety net that prevents runtime crashes from unhandled data shapes. This exhaustiveness checking is similar to what languages like Haskell, Rust, and Scala provide with their pattern matching, and it is one of the features that makes Elixir code robust in production.
Pattern Matching in Control Flow
Beyond destructuring, pattern matching serves as Elixir’s primary control flow mechanism. The case, cond, and with constructs all leverage pattern matching:
case File.read("config.json") do
{:ok, content} -> Jason.decode!(content)
{:error, reason} -> Logger.error("Failed to read: #{reason}")
end
with {:ok, user} <- fetch_user(id),
{:ok, _} <- validate_user(user),
{:ok, result} <- process_user(user) do
{:ok, result}
else
{:error, :not_found} -> {:error, "User not found"}
{:error, reason} -> {:error, reason}
endThe with construct is particularly elegant — it chains multiple pattern-matching operations and short-circuits on the first failure, all within a single expression.
The Pipe Operator
The pipe operator |> is Elixir’s answer to function composition. It passes the result of the left expression as the first argument to the function on the right, creating left-to-right data pipelines that read naturally:
def process_users(users) do
users
|> Enum.filter(& &1.active)
|> Enum.map(& &1.name)
|> Enum.join(", ")
endWithout the pipe operator, the same logic requires deeply nested function calls that are difficult to read and modify. The pipe operator makes the data flow explicit — each line is a transformation step, and the pipeline reads like a specification of the processing logic.
Pipes work with any function where the data is the first argument. This convention is deeply embedded in Elixir’s standard library — nearly every function in Enum, Map, String, and other modules takes the data structure as the first parameter.
The Enum and Stream Modules
The Enum module provides eager functions for working with collections — map, filter, reduce, sort, uniq, group_by, and dozens more. These functions compose naturally with pipes:
users = [
%{name: "Alice", age: 30, active: true},
%{name: "Bob", age: 17, active: true},
%{name: "Charlie", age: 25, active: false}
]
result = users
|> Enum.filter(&(&1.active and &1.age >= 18))
|> Enum.map(& &1.name)
|> Enum.sort()For lazy evaluation over large or infinite collections, Elixir provides the Stream module. Streams compose the same operations but defer computation until the result is needed:
1..1_000_000
|> Stream.filter(&(rem(&1, 2) == 0))
|> Stream.map(&(&1 * 2))
|> Enum.take(10)Only the first 10 matching elements are computed — Stream processes elements one at a time rather than creating intermediate collections at each step. This is particularly valuable when working with large datasets or infinite sequences.
OTP and the Actor Model
OTP (Open Telecom Platform) provides battle-tested abstractions for building concurrent, fault-tolerant systems. Elixir processes are lightweight — each process uses only a few kilobytes of memory, allowing you to run hundreds of thousands of concurrent processes on a single machine.
GenServer
GenServer is the fundamental building block for stateful server processes. It encapsulates state and handles synchronous (call) and asynchronous (cast) messages:
defmodule Counter do
use GenServer
def start_link(initial_count) do
GenServer.start_link(__MODULE__, initial_count, name: __MODULE__)
end
def increment do
GenServer.cast(__MODULE__, :increment)
end
def value do
GenServer.call(__MODULE__, :value)
end
@impl true
def handle_cast(:increment, state) do
{:noreply, state + 1}
end
@impl true
def handle_call(:value, _from, state) do
{:reply, state, state}
end
endSupervisors and Supervision Trees
Supervisors monitor child processes and restart them according to a defined strategy when they crash. This is the foundation of Elixir’s “let it crash” philosophy — instead of writing defensive error-handling code, you design systems where failures are isolated and automatically recovered:
defmodule MyApp.Supervisor do
use Supervisor
def start_link(_) do
Supervisor.start_link(__MODULE__, :ok, name: __MODULE__)
end
@impl true
def init(:ok) do
children = [
{Counter, 0},
{DatabaseWorker, []}
]
Supervisor.init(children, strategy: :one_for_one)
end
endWhen a child process crashes, the supervisor restarts it according to the configured strategy — :one_for_one restarts only the failed child, :one_for_all restarts all children, and :rest_for_one restarts the failed child and any processes started after it. This hierarchical supervision model is inspired by Erlang’s OTP and has been proven in production telecom systems for over 30 years.
Phoenix and LiveView
Phoenix is Elixir’s web framework, built on top of OTP. It provides real-time capabilities through WebSockets and Phoenix Channels. Phoenix LiveView enables rich, interactive user interfaces without writing JavaScript — state changes on the server are pushed to the client over a persistent connection. Chris McCord, the creator of Phoenix, has demonstrated LiveView handling thousands of concurrent connections with sub-50ms latency, showcasing the power of the BEAM for real-time web applications.
Mix and the Elixir Toolchain
Elixir ships with Mix, a build tool that handles project creation, dependency management, testing, and environment configuration. The Hex package manager hosts thousands of packages. ExUnit is the built-in testing framework with first-class support for asynchronous tests and descriptive assertions.
Property-Based Testing with StreamData
Elixir’s StreamData library integrates with ExUnit to provide property-based testing — instead of writing specific examples, you define properties that should hold for all inputs:
defmodule ListTest do
use ExUnit.Case
use ExUnitProperties
property "reversing a list twice returns the original" do
check all list <- list_of(integer()) do
assert list |> Enum.reverse() |> Enum.reverse() == list
end
end
endProperty-based testing catches edge cases that example-based tests miss, making it particularly valuable for data transformation and validation logic.
Ecto for Database Access
Ecto is Elixir’s database wrapper and query generator. It provides a functional approach to database access with schemas, changesets, and composable queries:
defmodule MyApp.User do
use Ecto.Schema
schema "users" do
field :name, :string
field :email, :string
field :age, :integer, default: 0
timestamps()
end
def changeset(user, attrs) do
user
|> Ecto.Changeset.cast(attrs, [:name, :email, :age])
|> Ecto.Changeset.validate_required([:name, :email])
|> Ecto.Changeset.validate_format(:email, ~r/@/)
|> Ecto.Changeset.validate_number(:age, greater_than_or_equal_to: 0)
end
endEcto’s changeset pipeline is a great example of functional composition in practice — each validation step is a pure function that transforms the changeset, and errors accumulate across all validations rather than short-circuiting on the first failure.
FAQ
How does Elixir compare to Erlang?
Elixir runs on the same BEAM VM as Erlang and has full interoperability with Erlang libraries. Elixir adds modern language features — macros, protocols, a more flexible syntax, and better tooling — while Erlang is more mature and has a longer track record in telecom and embedded systems.
Can Elixir handle millions of concurrent connections?
Yes. Elixir processes are lightweight (approximately 2-4 KB each), and the BEAM VM uses preemptive scheduling to share CPU time fairly. Discord handles millions of concurrent WebSocket connections using Elixir. The key is avoiding blocking operations — all I/O should be non-blocking, and CPU-intensive work should be distributed across processes.
What is the “let it crash” philosophy?
Instead of trying to handle every possible error condition in code, Elixir applications are designed so that processes crash cleanly when something unexpected happens. Supervisors detect these crashes and restart processes in a known good state. This approach produces simpler, more robust systems than defensive error handling with nested try-catch blocks.
How does pattern matching improve code quality?
Pattern matching makes code more declarative by combining destructuring, validation, and control flow in a single construct. Exhaustiveness checking ensures that all cases are handled, eliminating runtime errors from unhandled data shapes. This leads to code that is both more concise and more robust than equivalent imperative code.
Conclusion
Elixir combines functional programming principles with OTP’s battle-tested concurrency patterns to deliver a platform for building scalable, fault-tolerant systems. Pattern matching makes code expressive, the pipe operator creates readable data pipelines, and OTP provides robust abstractions for concurrency and fault recovery. With excellent tooling and a growing ecosystem, Elixir is an outstanding choice for modern backend development, real-time applications, and distributed systems.
For more on functional programming concepts, see Functional Programming Basics and explore Immutability Guide for deeper understanding of the principles that power Elixir.