Output & Running
Hello, World
io:format("Hello, World!~n"). package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
} Erlang prints with
io:format/1 and the ~n directive for the newline. Go's fmt.Println appends the newline automatically, but needs an explicit import "fmt" — there is no automatically-available prelude the way Erlang's io module is always reachable.Printing a value for inspection
Value = {ok, [1, 2, 3]},
io:format("~p~n", [Value]). package main
import "fmt"
func main() {
value := []int{1, 2, 3}
fmt.Printf("%v\n", value)
} Erlang's
~p ("pretty print") directive renders any term for humans. Go's %v ("value") verb inside Printf plays the closest role, printing a sensible default representation of nearly any value — though unlike Erlang, Go has no single term type that covers both a tag and a list at once, so this drops the ok wrapper Erlang's tuple carried.Dynamic vs. Static Types
Runtime checking vs. compile-time checking
% No type declaration anywhere — this only fails when it actually RUNS:
Add = fun(X, Y) -> X + Y end,
io:format("~p~n", [Add(2, 3)]). package main
import "fmt"
func add(x int, y int) int {
return x + y
}
func main() {
fmt.Println(add(2, 3))
} Erlang has no compile-time type checking at all —
Add("two", 3) would compile and only fail once that exact line executes. Go's func add(x int, y int) int means add("two", 3) is rejected at compile time — caught before the program runs, not discovered live.Type inference is local and limited
Double = fun(X) -> X * 2 end,
io:format("~p~n", [Double(21)]). package main
import "fmt"
func main() {
result := 21 * 2
fmt.Println(result)
} Go's
:= infers the type of a local variable from its initializer, so result needs no annotation — reading almost as unadorned as Erlang. Unlike Haskell or Scala, though, Go's inference stops there: function parameters and return types are always written out explicitly, and Go has no Hindley-Milner-style inference across function boundaries.The Missing Piece: No Pattern Matching
What replaces `case`: an ordinary `switch`
Classify = fun(N) ->
case N of
0 -> zero;
N when N > 0 -> positive;
_ -> negative
end
end,
io:format("~p~n", [Classify(-5)]). package main
import "fmt"
func classify(n int) string {
switch {
case n == 0:
return "zero"
case n > 0:
return "positive"
default:
return "negative"
}
}
func main() {
fmt.Println(classify(-5))
} This is the biggest structural loss moving from Erlang to Go: there is no pattern matching at all, anywhere in the language. Go's
switch without an operand (each case is its own boolean condition) is the closest replacement for Erlang's guarded case — but it is testing conditions, not destructuring a shape the way Erlang's case/function clauses do.No destructuring — access fields by name instead
Point = {point, 3, 4},
{point, X, Y} = Point,
io:format("~p and ~p~n", [X, Y]). package main
import "fmt"
type Point struct {
X int
Y int
}
func main() {
point := Point{X: 3, Y: 4}
fmt.Println(point.X, "and", point.Y)
} Erlang's
{point, X, Y} = Point destructures a whole tuple into named variables in one motion. Go has no equivalent syntax at all — every field must be accessed individually as point.X, point.Y. This single gap is the biggest everyday adjustment for an Erlang developer, showing up in nearly every function that used to be a one-line pattern match.No function clauses — one body, explicit branching inside
Describe = fun
Describe(0) -> "zero";
Describe(N) when N > 0 -> "positive";
Describe(_) -> "negative"
end,
io:format("~s~n", [Describe(-5)]). package main
import "fmt"
func describe(n int) string {
if n == 0 {
return "zero"
} else if n > 0 {
return "positive"
}
return "negative"
}
func main() {
fmt.Println(describe(-5))
} Erlang selects among several complete function bodies by matching the call against each clause head. Go allows exactly one function body per name — every "which case is this" decision has to be made with ordinary
if/else or switch statements inside that single body, rather than the language choosing a clause for you.{error, Reason} vs. value, err
`{ok, Value}`/`{error, Reason}` becomes two return values
Divide = fun(_, 0) -> {error, divide_by_zero};
(X, Y) -> {ok, X div Y}
end,
io:format("~p~n", [Divide(10, 0)]). package main
import (
"errors"
"fmt"
)
func divide(x, y int) (int, error) {
if y == 0 {
return 0, errors.New("divide by zero")
}
return x / y, nil
}
func main() {
result, err := divide(10, 0)
fmt.Println(result, err)
} Erlang bundles success-or-failure into one tagged tuple,
{ok, Value} or {error, Reason}. Go spreads exactly the same information across two separate return values instead: the last one is conventionally an error, nil on success. Both idioms exist for the identical reason — make failure an ordinary, inspectable value instead of an exception — just packaged differently.Nothing forces you to check the error — in either language
Divide = fun(_, 0) -> {error, divide_by_zero};
(X, Y) -> {ok, X div Y}
end,
% Nothing stops you from assuming success — this crashes with a
% badmatch if Divide returns {error, _} instead of {ok, _}:
try
{ok, Value} = Divide(10, 0),
io:format("~p~n", [Value])
catch
error:{badmatch, _} -> io:format("crashed: badmatch~n")
end. package main
import (
"errors"
"fmt"
)
func divide(x, y int) (int, error) {
if y == 0 {
return 0, errors.New("divide by zero")
}
return x / y, nil
}
func main() {
// Go compiles fine even if you never check err — this only misbehaves
// at runtime by silently using the zero value (0) as the result:
result, _ := divide(10, 0)
fmt.Println(result)
} Neither language's compiler forces you to handle the failure case — Erlang discovers a skipped check only when a badmatch actually crashes the process; Go lets you discard
err with _ and silently continue with a meaningless zero value, with no crash and no warning at all. This is a real weak spot in Go's design that linters (errcheck) exist specifically to patch — Go's type system, unlike Haskell's or Scala's, does not make ignoring an error a type error.Records vs. Structs
Records vs. structs
Point = {point, 3, 4},
{point, X, Y} = Point,
io:format("~p and ~p~n", [X, Y]). package main
import "fmt"
type Point struct {
X int
Y int
}
func main() {
point := Point{X: 3, Y: 4}
fmt.Println(point.X, "and", point.Y)
} Erlang's
-record(point, {x, y}). is compile-time sugar for a tagged tuple — nothing at runtime distinguishes it from any other tuple of the same shape. Go's type Point struct { X int; Y int } introduces a genuinely distinct, named type — the compiler will never let a Point be confused with an unrelated two-field struct, even one with identical field types.Methods live on the type, not in a free function
% Erlang has no "methods" — every function lives at module level
% and takes the record explicitly as its first argument:
Area = fun({rectangle, Width, Height}) -> Width * Height end,
io:format("~p~n", [Area({rectangle, 3, 4})]). package main
import "fmt"
type Rectangle struct {
Width, Height int
}
func (r Rectangle) Area() int {
return r.Width * r.Height
}
func main() {
rectangle := Rectangle{Width: 3, Height: 4}
fmt.Println(rectangle.Area())
} Erlang has no notion of a method — every function is a free function at module scope, and "the record it operates on" is just its first ordinary argument by convention. Go's receiver syntax,
func (r Rectangle) Area() int, attaches the function to the type itself, callable as rectangle.Area() — a genuinely new piece of syntax with no Erlang equivalent.Lists vs. Slices & Maps
Linked lists vs. slices
[Head | Tail] = [1, 2, 3],
io:format("~p and ~p~n", [Head, Tail]). package main
import "fmt"
func main() {
numbers := []int{1, 2, 3}
head, tail := numbers[0], numbers[1:]
fmt.Println(head, "and", tail)
} Erlang lists are singly-linked cons cells — cheap to prepend, linear to index. Go's slice is a different data structure entirely: a contiguous, resizable view over an array — cheap to index and iterate, but a genuinely new concept an Erlang developer has to learn (there is no
[Head | Tail] pattern; you slice with numbers[1:] instead).Map and filter need a loop
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]). package main
import "fmt"
func main() {
numbers := []int{1, 2, 3, 4, 5}
var evens []int
for _, n := range numbers {
doubled := n * 2
if doubled%2 == 0 {
evens = append(evens, doubled)
}
}
fmt.Println(evens)
} Erlang's
lists:map/2 and lists:filter/2 are built-in higher-order functions. Go's standard library only added a generic slices package recently and idiomatic Go still very often reaches for a plain for range loop with manual append instead — a real style difference, not just a syntax one: Go code tends to be more explicit about the mechanics of iteration where Erlang code describes the transformation directly.Key-value pairs: proplists/maps vs. Go maps
Ages = #{alice => 30, bob => 25},
io:format("~p~n", [maps:get(alice, Ages)]). package main
import "fmt"
func main() {
ages := map[string]int{"alice": 30, "bob": 25}
fmt.Println(ages["alice"])
} Erlang's map syntax
#{Key => Value} and Go's map[KeyType]ValueType{...} literal both give a genuine hash map. The key difference: Erlang maps are heterogeneous (any term can be a key or value), while Go maps are homogeneous and statically typed — every key must be string, every value must be int, enforced at compile time.Recursion
Factorial
Factorial = fun
Factorial(0) -> 1;
Factorial(N) when N > 0 -> N * Factorial(N - 1)
end,
io:format("~p~n", [Factorial(5)]). package main
import "fmt"
func factorial(n int) int {
if n == 0 {
return 1
}
return n * factorial(n-1)
}
func main() {
fmt.Println(factorial(5))
} The recursive structure survives the translation intact — a base case and a recursive case. What is lost is the clause-per-case readability: Go collapses both cases into one function body with an explicit
if, where Erlang keeps them as two separate, self-documenting clauses.Go does not guarantee tail-call optimization
% Erlang: the BEAM guarantees this never grows the call stack —
% tail-recursive functions can run forever in constant memory:
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)]). package main
import "fmt"
// Go's compiler does not guarantee this is optimized into a loop the
// way Erlang's BEAM guarantees for every tail call — for large inputs,
// idiomatic Go typically reaches for an explicit "for" loop instead:
func sumList(numbers []int) int {
total := 0
for _, n := range numbers {
total += n
}
return total
}
func main() {
fmt.Println(sumList([]int{1, 2, 3, 4, 5}))
} This is a genuine, important difference. Erlang's BEAM guarantees every tail call is optimized into a loop — an Erlang developer can write deeply recursive, accumulator-passing code with total confidence it runs in constant stack space forever. Go's specification makes no such promise, so idiomatic Go generally reaches for an explicit loop wherever Erlang would reach for tail recursion, rather than relying on an optimization the compiler is not obligated to perform.
Functions & Closures
Functions as values
Apply = fun(Function, Value) -> Function(Value) end,
Increment = fun(X) -> X + 1 end,
io:format("~p~n", [Apply(Increment, 41)]). package main
import "fmt"
func apply(function func(int) int, value int) int {
return function(value)
}
func main() {
increment := func(x int) int { return x + 1 }
fmt.Println(apply(increment, 41))
} Both languages treat functions as ordinary values — Erlang
funs and Go function literals (func(x int) int { ... }) are equally first-class. Go's func(int) int is a function-type annotation, making explicit in the type system something Erlang leaves entirely implicit.Closures capture by reference, not by value
MakeCounter = fun() ->
Count = 0,
fun() -> Count + 1 end
end,
Counter = MakeCounter(),
io:format("~p~n", [Counter()]). package main
import "fmt"
func makeCounter() func() int {
count := 0
return func() int {
count++
return count
}
}
func main() {
counter := makeCounter()
fmt.Println(counter())
fmt.Println(counter())
} Here the languages genuinely diverge: an Erlang closure captures its enclosing variables' values, and since those values can never change, calling the returned
fun repeatedly always produces the same result. Go closures capture variables by reference, and because Go allows real mutation, counter() called twice returns 1 then 2 — genuine mutable state trapped inside a closure, something no Erlang closure can express.Goroutines vs. Processes
`spawn` becomes the `go` keyword
% Erlang: spawn/1 starts a new, isolated process running the given fun
Pid = spawn(fun() -> io:format("running in a process~n") end),
io:format("~p~n", [is_pid(Pid)]). package main
import (
"fmt"
"sync"
)
func main() {
var waitGroup sync.WaitGroup
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
fmt.Println("running in a goroutine")
}()
waitGroup.Wait()
} Erlang's
spawn/1 and Go's go keyword both launch a new, independently-scheduled lightweight unit of concurrency in one word. The extra sync.WaitGroup ceremony in the Go example exists only because main would otherwise exit before the goroutine gets a chance to run — Erlang has no equivalent problem in this sandboxed context since the shell evaluator processes expressions to completion before moving on.The critical difference: goroutines are NOT isolated
% Erlang processes share NOTHING — a crash in one process cannot
% corrupt another process's state, ever, by construction:
self() ! {greeting, "hello"},
receive
{greeting, Message} -> io:format("~s~n", [Message])
end. package main
import (
"fmt"
"sync"
)
func main() {
// Goroutines share the SAME memory — this counter is directly
// mutated by multiple goroutines, requiring a mutex to stay safe:
var mutex sync.Mutex
var waitGroup sync.WaitGroup
counter := 0
for i := 0; i < 5; i++ {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
mutex.Lock()
counter++
mutex.Unlock()
}()
}
waitGroup.Wait()
fmt.Println(counter)
} This is the single most important thing to unlearn moving from Erlang to Go. Erlang processes share absolutely nothing — the only way to interact is
! and receive, which is precisely what makes a crashed process incapable of corrupting anything outside itself. Goroutines run in the same address space and share memory directly, so anything touched by more than one goroutine (like counter above) needs an explicit sync.Mutex — the exact category of bug (data races, corrupted shared state) that Erlang's process isolation makes structurally impossible.Channels vs. Message Passing
Mailboxes vs. channels
self() ! {greeting, "hello"},
receive
{greeting, Message} -> io:format("~s~n", [Message])
end. package main
import "fmt"
func main() {
messages := make(chan string)
go func() {
messages <- "hello"
}()
message := <-messages
fmt.Println(message)
} Erlang gives every process an implicit mailbox — any process can
! (send) to it, and receive pattern-matches to pick a specific message out of the queue. Go's channel is an explicit, typed conduit you must create with make(chan T) and pass to whichever goroutines need it — there is no implicit "the current goroutine's inbox" the way every Erlang process automatically has one.Selective receive vs. a typed channel per message shape
self() ! {tag_a, 1},
self() ! {tag_b, 2},
receive
{tag_b, Value} -> io:format("~p~n", [Value])
end. package main
import "fmt"
func main() {
tagA := make(chan int, 1)
tagB := make(chan int, 1)
tagA <- 1
tagB <- 2
value := <-tagB
fmt.Println(value)
} Erlang's
receive can selectively pluck a specific message shape out of a single mailbox, leaving other queued messages untouched for a later receive. Go has no equivalent to a mixed-message mailbox with pattern-based filtering — the idiomatic Go answer is usually a separate, distinctly-typed channel per kind of message, as shown, since one Go channel only ever carries one type.select vs. Selective Receive
Waiting on multiple channels at once
% Erlang: receive with multiple clauses waits for whichever
% message arrives first, matching against several patterns:
self() ! {from_a, "first"},
receive
{from_a, Message} -> io:format("~s~n", [Message]);
{from_b, Message} -> io:format("~s~n", [Message])
end. package main
import "fmt"
func main() {
channelA := make(chan string, 1)
channelB := make(chan string, 1)
channelA <- "first"
select {
case message := <-channelA:
fmt.Println(message)
case message := <-channelB:
fmt.Println(message)
}
} Go's
select statement is the direct structural analogue of Erlang's multi-clause receive: both block until exactly one of several possible incoming messages is ready, and both run only the branch matching whichever arrived. The difference is granularity — Erlang matches on the shape of a message from one shared mailbox, while Go's select chooses among entirely separate channels.panic/recover vs. Let It Crash
`panic` is Erlang's crash, `recover` is a supervisor
try 10 div 0 of
Result -> io:format("~p~n", [Result])
catch
error:badarith -> io:format("crashed: badarith~n")
end. package main
import "fmt"
func main() {
defer func() {
if recovered := recover(); recovered != nil {
fmt.Println("recovered from panic:", recovered)
}
}()
numbers := []int{1, 2, 3}
fmt.Println(numbers[10]) // index out of range — this panics
} Go's
panic/recover pair is philosophically close to Erlang's "let it crash": a panic unwinds the current goroutine's call stack looking for a recover, much like a crashing Erlang process looks for its supervisor. The big difference is scope — Go's recover is called explicitly inside a defer in the SAME goroutine, a manual, local mechanism, where Erlang's supervision tree is a separate, structural part of OTP that restarts a whole different process automatically, with no manual recover-equivalent call required anywhere.No built-in supervision tree
% Erlang: OTP's supervisor behaviour restarts a crashed child process
% automatically, according to a declared restart strategy — this is a
% first-class part of the language's standard library:
io:format("~p~n", [supervisor]). % the module name — just illustrating it exists package main
import "fmt"
func main() {
// Go has no equivalent standard-library concept — restarting a crashed
// goroutine (if you want that behavior at all) is something you build
// by hand, typically with a loop that re-launches on panic:
fmt.Println("no built-in supervisor")
} OTP's
supervisor behaviour — declaring which children to restart, how often, and under what strategy (one_for_one, one_for_all, and so on) — is a first-class, battle-tested part of Erlang's standard library. Go has nothing built in that plays this role; if you want a crashed goroutine automatically relaunched, you write that restart loop yourself, or reach for a third-party library — there is no OTP-equivalent shipped with the language.nil & Zero Values
No null in Erlang; nil very much exists in Go
% Erlang has no null — the closest equivalent is the atom undefined,
% which is just an ordinary atom with no special language treatment:
Value = undefined,
io:format("~p~n", [Value]). package main
import "fmt"
func main() {
var value *int // an uninitialized pointer defaults to nil
fmt.Println(value == nil)
} Erlang has no
null concept whatsoever — undefined is just a regular atom, with zero special compiler treatment. Go, like most C-descended languages, has a genuine nil that pointers, slices, maps, channels, and interfaces can all hold — and dereferencing a nil pointer panics at runtime exactly the way you would expect, something Erlang's type of "absence" cannot even attempt.Every type has an implicit "zero value"
% Erlang: every variable must be explicitly bound before use —
% there is no such thing as an "unset" variable with a default value:
X = 0,
io:format("~p~n", [X]). package main
import "fmt"
func main() {
var count int // zero value: 0
var name string // zero value: ""
var enabled bool // zero value: false
fmt.Println(count, name, enabled)
} Erlang requires every variable to be bound by a matching expression before it can be used — there is no notion of a variable existing but "empty." Go gives every type an automatic zero value (
0 for numbers, "" for strings, false for booleans, nil for pointers/slices/maps) so a declared-but-unassigned variable is always immediately usable — convenient, but a real source of Go bugs when a zero value is silently mistaken for meaningful data.Gotchas for Erlang Developers
`=` means something different than it looks like
X = 40,
% X = 41 would be a {badmatch,41} error — Erlang variables bind once:
io:format("~p~n", [X]). package main
import "fmt"
func main() {
x := 40
x = 41 // perfectly legal — Go variables are ordinary mutable storage
fmt.Println(x)
} Erlang variables genuinely cannot be rebound within a scope — it is a match failure, not silent mutation. Go's
= (after the initial := declaration) is ordinary, unrestricted reassignment, no different from most mainstream languages — there is no Erlang-style single-assignment discipline anywhere in Go by default.Build tools: rebar3/Mix vs. the `go` command
% rebar3 (Erlang's build tool):
% rebar3 new app myapp — create new project
% rebar3 compile — compile
% rebar3 shell — start a shell with the project loaded
% rebar3 eunit — run tests
% rebar3 release — build a deployable release // The go command (Go's build tool, built into the toolchain):
// go mod init myapp — create a new module
// go build — compile
// go run main.go — compile and run in one step
// go test — run tests
// go build -o myapp — build a deployable binary Both ecosystems bundle project creation, compiling, running, and testing behind one command-line tool. The philosophical difference: Go's tooling is a single first-party binary shipped with the language itself (no separate install, no competing build tools), where Erlang's
rebar3 (or the alternative mix shared with Elixir) is a separate, community-maintained tool you install on top of the language.