Output & Running
Hello, World
io:format("Hello, World!~n"). (println "Hello, World!") Erlang prints with
io:format/1 and the ~n directive for the newline. Clojure's println appends the newline automatically. Both languages run this top-level, with no wrapping module or main function required.Printing a value for inspection
Value = {ok, [1, 2, 3]},
io:format("~p~n", [Value]). (def value [:ok [1 2 3]])
(println value) Erlang's
~p ("pretty print") directive renders any term for humans. Clojure's println already prints any value — vectors, maps, keywords — in readable form with no separate formatting call needed, the way ~p handles any Erlang term uniformly.The Biggest Visual Shock: Lisp Syntax
Every operator moves to the front
Result = 2 + 3 * 4,
io:format("~p~n", [Result]). (def result (+ 2 (* 3 4)))
(println result) This is the single biggest surface shock moving from Erlang to Clojure: every operation, including plain arithmetic, is a parenthesized list with the operator or function name first —
(+ 2 (* 3 4)), not 2 + 3 * 4. There is no operator precedence to memorize because nesting the parentheses is the precedence; once this clicks, the rest of Clojure's syntax is just this one rule applied consistently everywhere.Function calls also move the name to the front
Result = lists:map(fun(N) -> N * 2 end, [1, 2, 3]),
io:format("~p~n", [Result]). (def result (map (fn [n] (* n 2)) [1 2 3]))
(println result) Erlang's
Module:function(Args) call and Clojure's (function args) call are the same idea — apply a function to arguments — but Clojure never uses commas or a distinct "call" syntax; a function call is simply a list whose first element is the thing being called. This uniformity is why Clojure code can so easily be manipulated as data (see "Code as Data" below).A Rare Shared Trait: Dynamic Typing
Neither language checks types at compile time
Add = fun(X, Y) -> X + Y end,
io:format("~p~n", [Add(2, 3)]). (defn add [x y] (+ x y))
(println (add 2 3)) Unlike Haskell, Scala, Go, F#, Rust, and every other statically-typed language in this comparison, Clojure agrees with Erlang here:
(add "two" 3) compiles and runs in both languages, only failing (or misbehaving) when that exact call actually executes. This is one of the few pairings on the site where the anchor and target share the same fundamental type-checking philosophy.Type checks still happen — just later
Value = 42,
io:format("~p~n", [is_integer(Value)]). (def value 42)
(println (integer? value)) Both languages let you ask "what type is this, right now" with a runtime predicate — Erlang's
is_integer/1 and Clojure's integer? serve the identical purpose and even share the trailing question-mark-as-predicate convention loosely (Erlang's is_ prefix, Clojure's ? suffix).Atoms vs. Keywords
Erlang atoms become Clojure keywords
Status = ok,
io:format("~p~n", [Status]). (def status :ok)
(println status) An Erlang atom (
ok, lowercase, no punctuation) and a Clojure keyword (:ok, with a leading colon) play the identical role: a unique, self-evaluating, interned constant, most often used as a tag or a map key. The leading colon is Clojure's equivalent of Erlang's lowercase-starts-an-atom convention — both exist so the reader instantly recognizes "this is a fixed label, not a variable."A keyword can look itself up in a map
% Erlang has no equivalent — looking up a key always calls a function,
% the key is never itself "callable":
Ages = #{alice => 30, bob => 25},
io:format("~p~n", [maps:get(alice, Ages)]). (def ages {:alice 30 :bob 25})
(println (:alice ages)) This has no Erlang equivalent at all: a Clojure keyword is itself callable as a one-argument function that looks itself up in a map —
(:alice ages) and (ages :alice) both work and mean the same thing. Erlang always requires the explicit maps:get/2 call; nothing in Erlang's atom syntax can act as a lookup function.{ok, X} vs. Tagged Vectors & Maps
`{ok, Value}` becomes `[:ok value]`
Lookup = fun(Key, List) ->
case lists:keyfind(Key, 1, List) of
{Key, Value} -> {ok, Value};
false -> error
end
end,
io:format("~p~n", [Lookup(b, [{a, 1}, {b, 2}])]). (defn lookup [key entries]
(if-let [value (get entries key)]
[:ok value]
[:error :not-found]))
(println (lookup :b {:a 1 :b 2})) Clojure has no built-in tuple type, but a small fixed-size vector plays exactly the role Erlang's tagged tuple does:
[:ok value] reads and destructures the same way {ok, Value} does. This is a convention, not a language feature — Clojure programmers commonly reach for a map with a :status key instead (see the next example), where Erlang idiom always prefers the tagged-tuple shape.A richer alternative: a map with a status key
% Erlang: a richer error needs a bigger tagged tuple, e.g.
% {error, not_found, Key} — arity grows with the data:
Result = {error, not_found, b},
io:format("~p~n", [Result]). (def result {:status :error :reason :not-found :key :b})
(println result) Where Erlang grows a tagged tuple's arity to carry more information (
{error, not_found, Key}), idiomatic Clojure often reaches for a map instead, since map keys are self-documenting and adding a new field never shifts existing positions. This is a genuine style difference: Erlang leans on tuple shape and pattern matching; Clojure leans on named keys and destructuring.Persistent Data Structures
Vectors: efficient indexed access, unlike Erlang lists
% Erlang lists are singly-linked — indexing the Nth element is O(n):
List = [10, 20, 30, 40],
io:format("~p~n", [lists:nth(3, List)]). (def numbers [10 20 30 40])
(println (nth numbers 2)) Erlang lists are cons cells — cheap to prepend, but indexing the Nth element means walking N cells. Clojure's vector (the default collection literal,
[...]) supports near-constant-time indexed access via a persistent trie structure, closer to an array in that respect while remaining fully immutable — a genuinely different performance profile for the same everyday "ordered collection of things" job."Updating" still produces a new value in both languages
% Erlang: nothing can mutate a list in place — "adding an element"
% always means building a new list:
Original = [1, 2, 3],
Updated = Original ++ [4],
io:format("~p and ~p~n", [Original, Updated]). (def original [1 2 3])
(def updated (conj original 4))
(println original "and" updated) Both languages guarantee the original value is completely untouched — Erlang's
Original and Clojure's original are both still valid and unchanged after "updating." The difference is efficiency under the hood: Clojure's persistent data structures use structural sharing so conj does not copy the whole vector, where Erlang's ++ genuinely walks and rebuilds the left-hand list — conceptually identical immutability, different performance characteristics.Pattern Matching vs. Destructuring
Destructuring a vector
[Head | Tail] = [1, 2, 3],
io:format("~p and ~p~n", [Head, Tail]). (let [[head & tail] [1 2 3]]
(println head "and" tail)) Erlang's
[Head | Tail] pattern and Clojure's [head & tail] destructuring both pull the first element and the rest apart in one binding. Clojure's destructuring is a let-binding feature, not a full pattern-matching system — there is no built-in Erlang-style case that branches on shape, which the next example covers.No built-in shape-based `case` — use `cond` instead
Describe = fun
Describe(0) -> "zero";
Describe(N) when N > 0 -> "positive";
Describe(_) -> "negative"
end,
io:format("~s~n", [Describe(-5)]). (defn describe [n]
(cond
(= n 0) "zero"
(> n 0) "positive"
:else "negative"))
(println (describe -5)) This is a real gap for an Erlang developer: core Clojure has no built-in construct that matches on a value's shape the way Erlang function clauses or a
case do. cond is a chain of independent boolean tests, closer to a sequence of if/else ifs than to genuine pattern matching. (The separate core.match library adds real pattern matching, but it is a dependency, not core syntax — unlike Erlang, where matching is fundamental to the language itself.)Recursion & `recur`
Factorial
Factorial = fun
Factorial(0) -> 1;
Factorial(N) when N > 0 -> N * Factorial(N - 1)
end,
io:format("~p~n", [Factorial(5)]). (defn factorial [n]
(if (zero? n)
1
(* n (factorial (dec n)))))
(println (factorial 5)) The base case and recursive case survive the translation, expressed as an
if rather than separate clauses — core Clojure functions do not dispatch on argument shape (see the previous section), so a single function body with a conditional replaces what Erlang expresses as two.`recur` makes a tail call explicit
SumList = fun
SumList([], Accumulator) -> Accumulator;
SumList([Head | Tail], Accumulator) -> SumList(Tail, Head + Accumulator)
end,
io:format("~p~n", [SumList([1, 2, 3, 4, 5], 0)]). (defn sum-list [numbers accumulator]
(if (empty? numbers)
accumulator
(recur (rest numbers) (+ (first numbers) accumulator))))
(println (sum-list [1 2 3 4 5] 0)) Erlang's BEAM silently guarantees every tail call is optimized, with nothing special to write. The JVM makes no such guarantee, so Clojure requires the explicit
recur keyword in tail position — the compiler verifies recur is genuinely a tail call and rewrites it into a loop, but you must ask for it by name rather than relying on it happening automatically the way an Erlang developer always can.Higher-Order Functions
Map and filter
Doubled = lists:map(fun(N) -> N * 2 end, [1, 2, 3, 4, 5]),
Evens = lists:filter(fun(N) -> N rem 2 =:= 0 end, Doubled),
io:format("~p~n", [Evens]). (def doubled (map #(* % 2) [1 2 3 4 5]))
(def evens (filter even? doubled))
(println evens) Both languages call these
map and filter. Clojure's #(* % 2) is anonymous-function shorthand — % stands for the single argument — playing the same terse role Erlang's fun(N) -> N * 2 end plays, just considerably shorter to write.The `->>` thread-last macro replaces nested calls
% Erlang has no pipe operator — nested calls read inside-out:
Result = lists:sum(lists:filter(fun(N) -> N rem 2 =:= 0 end,
lists:map(fun(N) -> N * 2 end, [1, 2, 3, 4, 5]))),
io:format("~p~n", [Result]). (->> [1 2 3 4 5]
(map #(* % 2))
(filter even?)
(reduce +)
println) Erlang has no pipe operator, so a chain of transformations reads inside-out (or needs a temporary variable per step). Clojure's
->> ("thread-last") macro threads each result into the last position of the next form, reading top-to-bottom in the same order the transformations actually happen — closer to Elixir's or F#'s pipe operator than anything native to Erlang.Code as Data
A quoted form is just a list — of data
% Erlang code is never itself an ordinary Erlang data value —
% there is no direct way to hold "the expression 2 + 3" as data:
Expression = {'+', 2, 3},
io:format("~p~n", [Expression]). (def expression '(+ 2 3))
(println expression)
(println (eval expression)) This is Clojure's defining trait: homoiconicity, code and data sharing the same representation.
'(+ 2 3) ("quote" prevents evaluation) is simultaneously valid Clojure syntax and an ordinary 3-element list — you can inspect it, take it apart, build a new one, and hand it to eval. Erlang can approximate the shape with a tuple, as shown, but that tuple is never itself executable code the way a quoted Clojure list is.Atoms/Refs vs. Processes
A Clojure atom is shared, mutable, and coordinated
% Erlang models a mutable counter as a recursive process holding
% state in its own arguments — there is no shared mutable location:
Counter = fun Counter(Count) -> Count end,
io:format("~p~n", [Counter(0)]). (def counter (atom 0))
(swap! counter inc)
(swap! counter inc)
(println @counter) This is the section where the two concurrency philosophies most visibly diverge. Idiomatic Erlang keeps mutable-feeling state inside a recursive process's own call stack — there is no shared location anything else can reach. Clojure's
atom IS a shared, mutable reference cell, safely updated with swap! (which retries under contention using compare-and-swap) — genuinely mutable state that many threads can see and coordinate on, something no single Erlang variable or process state can be from outside that process.No process isolation — everything shares one heap
% Erlang: a crash in one process cannot corrupt another process's
% state — isolation is total, by construction, with no exceptions:
self() ! {greeting, "hello"},
receive
{greeting, Message} -> io:format("~s~n", [Message])
end. (def shared-counter (atom 0))
; Every thread in this Clojure program can see and modify shared-counter —
; there is no per-process private heap the way Erlang guarantees:
(swap! shared-counter inc)
(println @shared-counter) Erlang's process isolation is total: nothing outside a process can ever directly touch its state, crashed or not. Clojure (on the JVM) has no equivalent isolation boundary — every atom, ref, and ordinary object lives on one shared heap, reachable by any thread that has a reference to it. Clojure's careful, controlled mutation primitives (atoms, refs, agents) exist specifically to make that shared-heap reality safe, rather than to recreate Erlang's isolation, which the JVM's memory model does not offer.
core.async vs. Message Passing
core.async channels echo Erlang mailboxes, loosely
self() ! {greeting, "hello"},
receive
{greeting, Message} -> io:format("~s~n", [Message])
end. ; core.async (not vendored in this in-browser sandbox — illustrating the
; API shape only):
; (require '[clojure.core.async :refer [chan >!! <!!]])
; (def messages (chan))
; (>!! messages "hello")
; (println (<!! messages))
(println "hello") Clojure's
core.async library (a separate dependency, not built into the core language) brings Go-style CSP channels to Clojure — chan, >!! (put), and <!! (take) play a role similar to Erlang's implicit per-process mailbox and !/receive, though channels are explicit, separately-created objects rather than something every process automatically has. This sandbox does not vendor core.async, so the snippet above is commented out and illustrates the shape only.Gotchas for Erlang Developers
`def` is closer to module attributes than variable binding
X = 40,
% X = 41 would be a {badmatch,41} error — Erlang variables bind once:
io:format("~p~n", [X]). (def x 40)
; (def x 41) would NOT error — it would just silently redefine x
; at the top level, which most Clojure style guides actively discourage:
(println x) Erlang enforces single assignment as a hard rule — rebinding is a match failure, everywhere, no exceptions. Clojure's top-level
def will quietly let you redefine a name (with a REPL warning, not an error) — legal, but considered poor style outside of REPL experimentation; inside a function, let-bound names genuinely cannot be reassigned, which is much closer to Erlang's guarantee.Both are REPL-first languages
% Erlang's shell (erl) is the traditional way to explore a running
% system interactively — this "session" mirrors that workflow:
Value = 2 + 2,
io:format("~p~n", [Value]). ; Clojure's REPL plays the identical role — define, redefine, and
; re-evaluate pieces of a running program interactively:
(def value (+ 2 2))
(println value) This is a genuine shared strength rather than a difference: both languages were designed around live, interactive development from the start. Erlang's
erl shell and Clojure's REPL both let you redefine a function in a running system and immediately see the new behavior — a workflow most statically-typed, compile-and-run languages in this comparison do not encourage nearly as strongly.