PONY λ M2 Modula-2

Elixir.CodeCompared.To/Go

An interactive executable cheatsheet comparing Elixir and Go

Elixir 1.17 Go 1.26.2
Compiled & Statically Typed
Hello, World
Every Go program is package main, an import block, and func main(). Each Go cell on this page shows only the lines you would actually write — the runner assembles that skeleton around them and imports exactly the packages you referenced.
IO.puts("Hello, World!")
fmt.Println("Hello, World!")
There is no script mode. elixir script.exs has no counterpart — a Go program is compiled to a single static binary by go build, and go run file.go merely hides the compile step. That binary carries no runtime to install: the whole reason teams reach for Go is that deployment is copy one file, where a release still ships the BEAM.
badarith at runtime → will not compile
Elixir finds a type error when the line finally runs — in production, at 3am, on the one input nobody tried. Go finds it before the binary exists. The Go cell below shows the conversion you must now write; the line that would not compile is in the comment.
value = "7" try do IO.puts(value + 1) rescue ArithmeticError -> IO.puts("badarith — discovered at RUNTIME") end
value := "7" // value + 1 -> invalid operation: mismatched types string and int number, err := strconv.Atoi(value) // the conversion is explicit AND can fail if err != nil { fmt.Println("bad number:", err) } else { fmt.Println(number + 1) }
This is the trade the whole page turns on. You give up the freedom to write anything and have it mean something at runtime; you get back a compiler that rejects a whole category of crash before the code ships. Note the second half of the bargain: Go has no implicit conversion anywhere — not even int to float64 — so strconv.Atoi returns an error alongside its value, and you handle it.
Rebinding → genuinely mutable variables
In Elixir count = count + 1 points the name at a new value and mutates nothing. Go's count++ writes to the storage itself — and anything holding a pointer to it sees the change.
count = 1 count = count + 1 # rebinds the NAME; the data is immutable IO.puts(count)
const limit = 10 // compile-time constant, untyped count := 1 // := declares and infers count++ // real mutation of real storage fmt.Println(count, limit)
Immutability stops being the physics of the language and becomes a discipline you maintain. := declares-and-infers inside a function; const is compile-time only (no const maps or slices exist). The compiler does enforce one useful habit Elixir never needed: an unused local variable is a compile error, not a warning.
#{} → Printf verbs
Go has no string interpolation at all. Values go into a format string through verbs — %s, %d, %v — and fmt.Sprintf is the equivalent of building a string with #{}.
name = "Ada" age = 36 IO.puts("#{name} is #{age}, next year #{age + 1}") greeting = "Hello, #{String.upcase(name)}!" IO.puts(greeting)
name := "Ada" age := 36 fmt.Printf("%s is %d, next year %d\n", name, age, age+1) greeting := fmt.Sprintf("Hello, %s!", strings.ToUpper(name)) fmt.Println(greeting)
Printf does not add a newline (that is what the explicit \n is for); Println does, and inserts spaces between its arguments. The verb worth memorizing first is %v — the default representation of any value, and %+v adds struct field names, making it the working equivalent of IO.inspect/1. Go's go vet checks that your verbs match your arguments, so a mismatch is usually caught before it prints garbage.
No truthiness, no nil-coalescing, no falsy
Elixir's "only nil and false are falsy" rule is generous by comparison: in Go a condition must be a bool, full stop. There is no || default, and an absent value is usually not nil at all — it is the type's zero value.
value = nil IO.puts(value || "default") if 0 do IO.puts("zero is truthy in Elixir") end
var name string // the zero value: "", never nil if name == "" { // the condition MUST be a bool name = "default" } fmt.Println(name) // if 0 { } -> non-boolean condition in if statement fmt.Println(0 == 0)
Every Go type has a zero value and variables are always initialized to it: 0 for numbers, "" for strings, false for bools, nil for pointers, slices, maps, channels, and interfaces. That is why "was it set?" so often needs a second return value or a pointer — the value alone cannot tell you apart from a legitimate 0. There is no || fallback and no ternary; write the if.
Pattern Matching Is Gone
= matches → := merely assigns
This is the single largest thing to unlearn. Elixir's = asserts a shape and binds every variable in it; Go's := only names a value. The only destructuring Go has is positional multiple assignment, and it does not look inside a struct or a map.
{:ok, port} = {:ok, 4000} IO.puts(port) %{name: name} = %{name: "Ada", age: 36} IO.puts(name) [first | rest] = [1, 2, 3] IO.inspect({first, rest})
type Config struct { Name string Port int } config := Config{Name: "Ada", Port: 4000} name := config.Name // field access, one field at a time port := config.Port fmt.Println(name, port) numbers := []int{1, 2, 3} first, rest := numbers[0], numbers[1:] // positional, and only positional fmt.Println(first, rest)
Nothing here asserts a shape. %{name: name} = person would raise a MatchError the moment person stopped having that key; config.Name cannot fail, because the compiler already proved the field exists. You trade a runtime assertion for a compile-time guarantee — but you also lose the ability to say "this must look like this" in one line.
Multi-clause heads → one body and a switch
A Go function name may be defined exactly once. Every clause you would have written as a separate head collapses into a single body that inspects its argument and branches — and no compiler checks that you covered every case.
defmodule Area do def area({:circle, radius}), do: 3.14159 * radius * radius def area({:rectangle, width, height}), do: width * height end IO.puts(Area.area({:circle, 2.0})) IO.puts(Area.area({:rectangle, 3, 4}))
type Shape struct { Kind string Radius float64 Width float64 Height float64 } func area(shape Shape) float64 { switch shape.Kind { case "circle": return 3.14159 * shape.Radius * shape.Radius case "rectangle": return shape.Width * shape.Height } return 0 // no FunctionClauseError — an unhandled shape returns zero, silently } fmt.Println(area(Shape{Kind: "circle", Radius: 2})) fmt.Println(area(Shape{Kind: "rectangle", Width: 3, Height: 4}))
Notice what the last line costs you. In Elixir an unmatched shape raises FunctionClauseError immediately, at the boundary, with the offending argument in the message; here it returns 0 and the bug surfaces three layers away. Go's switch has no exhaustiveness check, so a new Kind compiles fine and silently falls through. Modeling this with an interface instead (see the Interfaces section) is the idiomatic fix — that is checked.
Guards → the expressionless switch
Guard clauses have one decent Go counterpart: a switch with no subject, whose cases are arbitrary boolean expressions. It reads top-to-bottom like a guard ladder and stops at the first true case — no break needed, because Go does not fall through.
defmodule Classify do def size(count) when count < 0, do: "invalid" def size(count) when count == 0, do: "empty" def size(count) when count < 100, do: "small" def size(_count), do: "large" end IO.inspect(Enum.map([-1, 0, 5, 500], &Classify.size/1))
func size(count int) string { switch { case count < 0: return "invalid" case count == 0: return "empty" case count < 100: return "small" default: return "large" } } for _, count := range []int{-1, 0, 5, 500} { fmt.Print(size(count), " ") } fmt.Println()
The shape survives the translation almost intact, and Go's cases are freer than guards — any expression is allowed, including function calls, where Elixir restricts guards to a fixed safe set. What is lost is the binding: a guard sits on a head that has already destructured its argument, so the branch and the extraction happen together. Here they are two separate steps.
Atoms → typed constants and iota
Go has no atoms. The nearest idiom is a named integer type plus a const block using iota, which auto-increments down the block — and unlike an atom, the compiler then refuses to mix it with any other type.
status = :running case status do :pending -> IO.puts("waiting") :running -> IO.puts("working") :done -> IO.puts("finished") end IO.inspect(status)
type Status int const ( Pending Status = iota // 0 Running // 1 Done // 2 ) func (status Status) String() string { return [...]string{"pending", "running", "done"}[status] } status := Running switch status { case Pending: fmt.Println("waiting") case Running: fmt.Println("working") case Done: fmt.Println("finished") } fmt.Println(status)
The gain is real: Status is a distinct type, so passing an int where a Status belongs will not compile, and a typo cannot invent a new value the way :runnning silently can. The losses are equally real — the value is an integer at runtime, so printing it needs that hand-written String() method, and the switch is still not checked for exhaustiveness (the exhaustive linter exists precisely because the compiler will not do it).
Errors Are Values — Two of Them
{:ok, value} → (value, error)
The convention you already live by is the language rule here: failure is an ordinary return value, not a thrown thing. The difference is packaging — Elixir puts both in one tagged tuple you match on, Go returns them as two separate values you test with an if.
defmodule Port do def parse(text) do case Integer.parse(text) do {number, ""} -> {:ok, number} _ -> {:error, "not a number: #{text}"} end end end for text <- ["4000", "nope"] do case Port.parse(text) do {:ok, number} -> IO.puts("port #{number}") {:error, reason} -> IO.puts("failed: #{reason}") end end
func parsePort(text string) (int, error) { number, err := strconv.Atoi(text) if err != nil { return 0, fmt.Errorf("not a number: %s", text) } return number, nil } for _, text := range []string{"4000", "nope"} { number, err := parsePort(text) if err != nil { fmt.Println("failed:", err) continue } fmt.Println("port", number) }
Two things are worse and one is better. Worse: the two values are independent, so nothing stops you reading number when err is non-nil — a tagged tuple makes that unrepresentable — and every failing path must still return something for the value slot, hence the 0. Better: error is an interface, not a bare atom or string, so an error can carry structured data and be inspected without parsing text. And err stays spelled that way — it is as fixed a convention in Go as _from is in a handle_call.
with → the if err != nil ladder
There is no with, and nothing plays its role. Each step's failure is handled at the step, in three lines, every time. This is the verbosity Go is famous for — and it is deliberate: the error path is as visible as the happy path.
defmodule Pipeline do def run(text) do with {number, ""} <- Integer.parse(text), true <- number > 0 do {:ok, number * 2} else _ -> {:error, "bad input"} end end end IO.inspect(Pipeline.run("21")) IO.inspect(Pipeline.run("-1"))
func double(text string) (int, error) { number, err := strconv.Atoi(text) if err != nil { return 0, fmt.Errorf("bad input: %w", err) // %w WRAPS the cause } if number <= 0 { return 0, errors.New("bad input: not positive") } return number * 2, nil } fmt.Println(double("21")) fmt.Println(double("-1"))
What with gave you — one happy path, one else for every failure — has to be rebuilt by hand, and the temptation to write a bare if err != nil { return err } everywhere loses exactly the context with/else let you add centrally. The counterweight is %w: it wraps the original error inside the new one, building a chain that errors.Is and errors.As can walk later, which is closer to an Elixir exception's stacktrace than to a flat {:error, reason}.
defexception → a type with an Error() method
Anything with an Error() string method is an error — no declaration, no registration. errors.As then walks the wrapped chain looking for that concrete type and, if it finds one, fills in your variable with it: the closest Go comes to rescue error in NotFoundError.
defmodule NotFoundError do defexception [:key] def message(error), do: "not found: #{error.key}" end try do raise NotFoundError, key: "user-7" rescue error in NotFoundError -> IO.puts("missing key: #{error.key}") end
type NotFoundError struct { Key string } func (notFound NotFoundError) Error() string { return "not found: " + notFound.Key } func lookup(key string) error { return fmt.Errorf("lookup failed: %w", NotFoundError{Key: key}) } err := lookup("user-7") var notFound NotFoundError if errors.As(err, &notFound) { // unwraps until it finds this type fmt.Println("missing key:", notFound.Key) } fmt.Println(err)
Use errors.Is when you are comparing against a known sentinel value (errors.Is(err, os.ErrNotExist)) and errors.As when you need the concrete type's fields, as here. Both walk the %w chain, so a wrapped error five layers deep is still identifiable — which is what makes returning errors up a call stack survivable without the stacktrace an Elixir exception would have carried for free.
raise / rescue → panic / recover (rarely)
panic is not Go's error mechanism — it is reserved for "this program is broken," roughly where you would raise a RuntimeError that nobody is meant to rescue. recover works in exactly one place: inside a deferred function. There is no rescue expression and no try block.
result = try do raise ArgumentError, "boom" rescue error -> "rescued: #{Exception.message(error)}" end IO.puts(result)
func guarded() (message string) { defer func() { if recovered := recover(); recovered != nil { message = fmt.Sprintf("recovered: %v", recovered) } }() panic("boom") } fmt.Println(guarded())
The named return value (message string) is not decoration — it is the only way the deferred closure can change what the function returns, because by the time recover runs the return statement is already past. Reaching for this pattern to emulate rescue is considered a code smell in Go: a library that panics across its own API boundary is a library with a bug. And it does not compose the way rescue does — see the concurrency section for the part that really matters.
after → defer (and you will use it constantly)
defer schedules a call for when the enclosing function returns — not the block, the function — and deferred calls run last-in-first-out. Arguments are evaluated at the moment you write defer, not when it fires.
try do IO.puts("open") IO.puts("work") after IO.puts("close") end
func process() { fmt.Println("open") defer fmt.Println("close") // runs when process() returns, however it returns fmt.Println("work") } process()
Where try/after is a construct you reach for occasionally, defer is everywhere in real Go — it is how files, locks, connections, and transactions are released, written on the line immediately after acquisition so the pairing is impossible to miss. It also fires when the function panics, which is what makes the recover idiom above work at all.
Mutation & Aliasing Return
Persistent lists → slices that alias
A Go slice is a three-word header — pointer, length, capacity — pointing at a backing array. Copying the slice copies the header, not the elements, so two "different" slices can write to the same memory.
original = [1, 2, 3] updated = List.replace_at(original, 0, 99) IO.inspect(original) IO.inspect(updated)
original := []int{1, 2, 3} sameArray := original // copies the HEADER, not the elements sameArray[0] = 99 // writes through to the shared backing array fmt.Println(original, sameArray) window := original[1:3] // a VIEW, still sharing the same memory window[0] = 42 fmt.Println(original, window)
Elixir made "who else can see this change?" an unaskable question. In Go it is a question you must ask on every assignment and every function call, because slices, maps, and channels all pass a reference while structs and arrays copy. Slicing is where the subtlest Go bugs live: original[1:3] is a window onto the same array, and append may or may not reallocate depending on capacity, so whether a write shows through is not stable across inputs.
%{map | key: value} → mutating one shared map
There is no update syntax and no persistent map. A Go map value is a reference to one hash table; assigning it to another name gives you a second name for the same table.
person = %{name: "Ada", age: 36} older = %{person | age: 37} IO.inspect(person.age) IO.inspect(older.age)
person := map[string]int{"age": 36, "shoe": 7} older := person // a map is a REFERENCE — one table, two names older["age"] = 37 fmt.Println(person["age"], older["age"]) independent := maps.Clone(person) // an explicit copy, when you want one independent["age"] = 99 fmt.Println(person["age"], independent["age"])
The %{map | key: value} form also asserted the key already exists; nothing here does — older["typo"] = 1 silently adds a key. maps.Clone (Go 1.21+) gives the copy semantics you are used to, but it is a shallow copy and, crucially, you have to remember to ask for it. This is the habit that costs Elixir developers the most: passing a map into a function no longer guarantees it comes back unchanged.
Values, copies & explicit pointers
Structs are values: passing one to a function copies it, and the function's changes evaporate. To let a function mutate the caller's value you pass its address with & and take a pointer parameter *T.
defmodule Counter do def increment(state), do: %{state | count: state.count + 1} end state = %{count: 0} IO.inspect(Counter.increment(state).count) IO.inspect(state.count) # untouched — it always is
type Counter struct { Count int } func incrementCopy(counter Counter) { counter.Count++ // mutates a COPY; the caller sees nothing } func incrementPointer(counter *Counter) { counter.Count++ // mutates the caller's value } counter := Counter{} incrementCopy(counter) fmt.Println(counter.Count) incrementPointer(&counter) fmt.Println(counter.Count)
The Elixir habit of returning a new state from every function still works in Go and is often the better design — but it is now a choice with a cost, since a large struct really is copied. Pointers here are far tamer than C's: no arithmetic, no manual free, and the garbage collector keeps whatever is still reachable, so &counter on a local is perfectly safe and the value simply escapes to the heap.
Enum → the for Loop
Enum.filter |> Enum.map |> Enum.sum → one loop
There is no Enum. Go has exactly one loop keyword, for, and no map/filter/reduce over slices in the standard library — a deliberate omission, not an oversight.
total = [1, 2, 3, 4, 5, 6] |> Enum.filter(&(rem(&1, 2) == 0)) |> Enum.map(&(&1 * &1)) |> Enum.sum() IO.puts(total)
numbers := []int{1, 2, 3, 4, 5, 6} total := 0 for _, number := range numbers { if number%2 == 0 { total += number * number } } fmt.Println(total)
The range form yields index and value, which is why the _ is there — forgetting it is the classic first-week bug, since for number := range numbers silently iterates the indices. The loop is also a single pass where the pipeline was three, and it allocates nothing. Go's position is that without lightweight lambda syntax a chain would not actually read better; whether you agree, this is what every Go codebase looks like, so consistency is on its side.
Map.get / Map.fetch → indexing and comma-ok
Indexing a Go map never fails: a missing key returns the value type's zero value. To tell "missing" from "present and zero" you use the two-value form, which is Map.fetch/2 with the tuple flattened.
ages = %{"Ada" => 36, "Grace" => 45} IO.inspect(Map.get(ages, "Ada")) IO.inspect(Map.get(ages, "Alan")) IO.inspect(Map.fetch(ages, "Alan")) IO.inspect(Map.keys(ages))
ages := map[string]int{"Ada": 36, "Grace": 45} fmt.Println(ages["Ada"]) fmt.Println(ages["Alan"]) // 0 — the ZERO VALUE, not nil, no error age, found := ages["Alan"] // the comma-ok form ≈ Map.fetch/2 fmt.Println(age, found) names := make([]string, 0, len(ages)) for name := range ages { // one variable over a map = the KEYS names = append(names, name) } slices.Sort(names) // map order is deliberately randomized fmt.Println(names)
Go randomizes map iteration order on purpose, varying it run to run so no code can accidentally depend on it — stricter than Elixir's maps, whose small-map ordering is merely unspecified. So "print these sorted" means collecting the keys and sorting them yourself, and there is no Map.keys/1. Note also that delete, len, and append are built-in functions, not methods: there is no Map module to reach into.
Enum.sort_by → slices.SortFunc, in place
The generic slices package (Go 1.21+) is the closest thing to Enum you will find. SortFunc takes a three-way comparator returning negative, zero, or positive — and it sorts the slice in place rather than returning a new one.
people = [%{name: "Grace", age: 45}, %{name: "Ada", age: 36}] sorted = Enum.sort_by(people, & &1.age) IO.inspect(Enum.map(sorted, & &1.name))
type Person struct { Name string Age int } people := []Person{{"Grace", 45}, {"Ada", 36}} slices.SortFunc(people, func(first, second Person) int { return first.Age - second.Age }) for _, person := range people { fmt.Print(person.Name, " ") } fmt.Println()
In place means the caller's slice is reordered — there is no sorted and people side by side afterward, only people. slices also gives you Contains, Index, IndexFunc, Reverse, Max, and Min, which covers a useful slice of Enum; what it deliberately does not give you is Map or Filter.
Stream → range-over-func iterators
Go 1.23 added the one genuinely lazy construct in the language: range over a function. An iterator is a function taking a yield callback; returning false from yield means the consumer broke out, and the producer stops. That is Stream, spelled as a protocol between two functions.
result = 1 |> Stream.iterate(&(&1 + 1)) |> Stream.map(&(&1 * &1)) |> Enum.take(5) IO.inspect(result)
func squares() func(func(int) bool) { return func(yield func(int) bool) { for number := 1; ; number++ { // infinite, and that is fine if !yield(number * number) { return // the consumer broke out } } } } taken := 0 for square := range squares() { fmt.Print(square, " ") taken++ if taken == 5 { break } } fmt.Println()
That signature has a name in the standard library — iter.Seq[int], with iter.Seq2 for key/value pairs — and the func(func(int) bool) written out above is exactly what it aliases. The laziness is genuine, so an infinite generator is safe. What is missing is composition: there is no Stream.map to stack on top, so combinators over iterators are something you write or import.
Structs & Methods
defstruct → struct, and capitalization means export
A Go struct is close to defstruct with one rule that has no Elixir counterpart: capitalization is visibility. A capitalized field or function is exported from its package; a lowercase one is package-private, enforced by the compiler.
defmodule Person do defstruct name: "", age: 0 end person = %Person{name: "Ada", age: 36} IO.puts(person.name) older = %{person | age: 37} IO.inspect({person.age, older.age})
type Person struct { Name string // exported — visible outside this package Age int // fields you omit get their ZERO value, no defaults exist } person := Person{Name: "Ada", Age: 36} fmt.Println(person.Name) older := person // a struct is a VALUE — this is a full copy older.Age = 37 fmt.Println(person.Age, older.Age)
That copy is doing the work %{person | age: 37} did, but by different physics: Elixir shares structure and forbids mutation, Go duplicates the bytes and permits it. There are no field defaults — Person{} gives "" and 0, and a constructor function (NewPerson) is the conventional way to supply anything else. There is also no __struct__ key and no runtime reflection needed to know the type: it is in the static type.
Module.function(data) → methods on the type
Behavior moves onto the data. A method is an ordinary function with a receiver declared before its name, and the receiver is the argument you used to pass first. A pointer receiver (*Rectangle) can mutate; a value receiver gets a copy.
defmodule Rectangle do defstruct width: 0, height: 0 def area(%Rectangle{width: width, height: height}), do: width * height def scale(rectangle, factor) do %{rectangle | width: rectangle.width * factor, height: rectangle.height * factor} end end rectangle = %Rectangle{width: 3, height: 4} IO.puts(Rectangle.area(rectangle)) IO.puts(Rectangle.area(Rectangle.scale(rectangle, 2)))
type Rectangle struct { Width float64 Height float64 } func (rectangle Rectangle) Area() float64 { return rectangle.Width * rectangle.Height } func (rectangle *Rectangle) Scale(factor float64) { rectangle.Width *= factor // pointer receiver: mutates in place rectangle.Height *= factor } rectangle := Rectangle{Width: 3, Height: 4} fmt.Println(rectangle.Area()) rectangle.Scale(2) fmt.Println(rectangle.Area())
This is not objects returning: there is no inheritance, no super, and no class — just functions with a receiver, which the compiler dispatches statically. The one thing to watch is mixing receiver kinds on the same type, since Scale's pointer receiver means only an addressable value can call it, and only the pointer type satisfies an interface that includes it. Pick one style per type and stay with it.
defdelegate → struct embedding
Embedding a type without a field name promotes its fields and methods to the outer struct — defdelegate for every member at once, written in one line. It is composition, not inheritance: nothing is overridden and no dynamic dispatch happens.
defmodule Timestamps do def describe(record), do: "created at #{record.created_at}" end defmodule Article do defstruct title: "", created_at: "2026-07-30" defdelegate describe(record), to: Timestamps end article = %Article{title: "Hello"} IO.puts(Article.describe(article))
type Timestamps struct { CreatedAt string } func (timestamps Timestamps) Describe() string { return "created at " + timestamps.CreatedAt } type Article struct { Timestamps // EMBEDDED — no field name Title string } article := Article{Timestamps: Timestamps{CreatedAt: "2026-07-30"}, Title: "Hello"} fmt.Println(article.Describe()) // promoted from the embedded struct fmt.Println(article.CreatedAt) // so are its fields
Promotion is shallow and resolved at compile time by name: an Article.Describe of its own simply shadows the embedded one, with no super to call and no runtime lookup. The honest comparison is to defdelegate and to use's __using__ injection — Go generates nothing and rewrites nothing, so what you can reach is exactly what the two type definitions say.
Protocols → Implicit Interfaces
defprotocol / defimpl → an interface nobody declares
This is the concept that will feel best. A Go interface lists method signatures, and any type with those methods satisfies it automatically — no defimpl, no registration, no consolidation pass. The Elixir cell is display-only because AtomVM compiles a protocol with two implementations too slowly to run in the browser — not because the dispatch is missing.
defprotocol Describable do def describe(value) end defimpl Describable, for: Integer do def describe(number), do: "the number #{number}" end defimpl Describable, for: BitString do def describe(text), do: "the text #{text}" end IO.puts(Describable.describe(42)) IO.puts(Describable.describe("hello"))
type Describable interface { Describe() string } type Number int func (number Number) Describe() string { return fmt.Sprintf("the number %d", int(number)) } type Text string func (text Text) Describe() string { return "the text " + string(text) } for _, value := range []Describable{Number(42), Text("hello")} { fmt.Println(value.Describe()) // dynamic dispatch, checked at compile time }
Satisfaction is structural and checked where the value is used, so you can define an interface describing a type you did not write and did not import — something defimpl can also do, but only by naming the protocol. The Go convention is to keep interfaces tiny (one or two methods) and declare them in the consumer package rather than beside the implementation, which inverts where the coupling lives. The cost is discoverability: nothing in Number says it implements anything.
is_integer guards → the type switch
A type switch is the one construct in Go that branches on a value's shape and binds a correctly typed variable in the same breath — the closest thing to a pattern match the language has. It only works on an interface value, most often the empty one, any.
describe = fn value when is_integer(value) -> "integer #{value}" value when is_binary(value) -> "string #{value}" value when is_list(value) -> "list of #{length(value)}" _ -> "something else" end IO.puts(describe.(42)) IO.puts(describe.("hi")) IO.puts(describe.([1, 2]))
func describe(value any) string { switch typed := value.(type) { case int: return fmt.Sprintf("integer %d", typed) case string: return "string " + typed case []int: return fmt.Sprintf("list of %d", len(typed)) default: return "something else" } } fmt.Println(describe(42)) fmt.Println(describe("hi")) fmt.Println(describe([]int{1, 2}))
Inside each case, typed has that concrete type, so the compiler still checks the body — the binding is the whole point. But reaching for any throws away the static guarantees the rest of the page was buying, and idiomatic Go treats it as a last resort: prefer an interface with a method, or generics, and keep the type switch for genuinely heterogeneous input like decoded JSON. The single-type form value.(int) exists too, and its comma-ok variant is how you avoid a panic on a wrong guess.
@behaviour / @callback → an interface at the boundary
A @behaviour is a contract the implementing module opts into by name, checked at compile time by Elixir. Go's equivalent is an interface — but nothing opts in, and the check happens at the call site, when you pass the value where the interface is expected.
defmodule Store do @callback fetch(String.t()) :: {:ok, String.t()} | {:error, String.t()} end defmodule MemoryStore do @behaviour Store @impl true def fetch(key) do if key == "greeting", do: {:ok, "hello"}, else: {:error, "missing #{key}"} end end IO.inspect(MemoryStore.fetch("greeting")) IO.inspect(MemoryStore.fetch("nope"))
type Store interface { Fetch(key string) (string, error) } type MemoryStore struct{} func (memory MemoryStore) Fetch(key string) (string, error) { if key == "greeting" { return "hello", nil } return "", fmt.Errorf("missing %s", key) } func greet(store Store) string { // takes the INTERFACE, not the type value, err := store.Fetch("greeting") if err != nil { return "no greeting" } return value } fmt.Println(greet(MemoryStore{}))
A @behaviour needs the implementing module to name it and needs a @callback spec to exist at all; MemoryStore names nothing. In exchange, dependency injection stops being a runtime module attribute or an application-config swap and becomes an ordinary parameter with a type the compiler enforces — which is also how test doubles work in Go: define the interface next to the consumer, pass a fake in the test.
Binaries → Strings, Bytes, Runes
The String module → the strings package
A Go string is an immutable UTF-8 byte sequence — the same physical thing as an Elixir binary. The operations live in the strings package as plain functions taking the string first, so the call shape barely changes.
text = " Hello, World " IO.puts(String.trim(text)) IO.puts(String.upcase(text)) IO.inspect(String.split("a,b,c", ",")) IO.puts(String.contains?(text, "World")) IO.puts(String.replace(text, "World", "Go"))
text := " Hello, World " fmt.Println(strings.TrimSpace(text)) fmt.Println(strings.ToUpper(text)) fmt.Println(strings.Split("a,b,c", ",")) fmt.Println(strings.Contains(text, "World")) fmt.Println(strings.ReplaceAll(text, "World", "Go"))
Strings are immutable in both languages, so these all return new values. Two habits to drop: there is no ? suffix convention (Contains, not contains?), and Replace takes a count — ReplaceAll is the one matching String.replace/3's default. And unlike the sigil-rich Elixir side, Go has no ~s, no ~w, and no heredoc: a backtick-quoted raw string is the only alternative literal.
String.length → bytes, runes, and len()
len(text) counts bytes, not characters — the trap String.length/1 spared you. Counting characters means counting runes (Go's name for a Unicode code point, an int32), and indexing by character means converting to []rune first.
text = "héllo" IO.puts(String.length(text)) IO.puts(byte_size(text)) IO.puts(String.at(text, 1)) for character <- String.graphemes(text), do: IO.write(character <> " ") IO.puts("")
text := "héllo" fmt.Println(len(text)) // 6 — BYTES, not characters fmt.Println(utf8.RuneCountInString(text)) // 5 — code points runes := []rune(text) fmt.Println(string(runes[1])) // "é", by character index for index, character := range text { // range yields BYTE INDEX + rune fmt.Print(index, string(character), " ") } fmt.Println()
Note the asymmetry that catches everyone: text[1] gives you a single byte (a byte, alias for uint8), while ranging over the same string decodes runes and reports the byte offset of each. Go stops at code points and has nothing like String.graphemes/1, so a combining accent or an emoji with a skin-tone modifier counts as several runes — grapheme clustering lives in golang.org/x/text, outside the standard library.
iodata → strings.Builder
Repeated <> concatenation reallocates every time, which is why Elixir has iodata. Go's answer is strings.Builder: an appendable buffer that hands you the finished string once.
parts = for number <- 1..5, do: "item#{number}" IO.puts(Enum.join(parts, ", "))
builder := strings.Builder{} for number := 1; number <= 5; number++ { if number > 1 { builder.WriteString(", ") } fmt.Fprintf(&builder, "item%d", number) } fmt.Println(builder.String())
A Builder is an io.Writer, which is why fmt.Fprintf can write straight into it — that one small interface is the join point for files, network connections, HTTP responses, and buffers alike, and learning it early pays for itself. For joining a slice with a separator, strings.Join(parts, ", ") is the direct Enum.join/2 and is what you would actually reach for here.
Functions, Closures & No Pipe
|> → named intermediate steps
There is no pipe operator and no method chaining on built-in types. The idiomatic replacement is the one Erlang developers already know: name each step. Nesting the calls instead is legal and reads backwards, so nobody does it past two levels.
result = " hello world " |> String.trim() |> String.upcase() |> String.replace(" ", "-") IO.puts(result)
text := " hello world " trimmed := strings.TrimSpace(text) shouted := strings.ToUpper(trimmed) result := strings.ReplaceAll(shouted, " ", "-") fmt.Println(result)
Go's functions do take their subject first, so the data-first shape the pipe depends on is already there — only the operator is missing, and proposals to add one have been consistently declined. Method chaining does work on types you define, by returning the receiver, so a fluent builder is possible; it is simply not how the standard library is written.
fn → func literals that can mutate what they capture
Anonymous functions are close cousins — until capture. An Elixir closure captures a value and can never change it; a Go closure captures the variable, so it reads and writes the same storage the enclosing function does.
multiplier = 3 triple = fn number -> number * multiplier end IO.inspect(Enum.map([1, 2, 3], triple)) IO.inspect(Enum.map([1, 2, 3], &(&1 * 2))) counter = 0 increment = fn -> counter + 1 end # returns a value; counter is untouched IO.inspect({increment.(), counter})
multiplier := 3 triple := func(number int) int { return number * multiplier } for _, number := range []int{1, 2, 3} { fmt.Print(triple(number), " ") } fmt.Println() counter := 0 increment := func() { counter++ } // captures the VARIABLE, and mutates it increment() increment() fmt.Println(counter)
There is no capture operator (&String.upcase/1) and no shorthand (&(&1 * 2)) — functions are values, so you pass strings.ToUpper by name, and everything else is a full func literal. That verbosity is exactly why Go declined to add map/filter. The capture-by-reference rule is also what makes the goroutine examples in the next section work at all — and what makes them need a mutex.
Dynamic typing → type parameters
Enum.map/2 works on anything because nothing is checked. Go gets the same reach with generics (1.18+): type parameters in square brackets, inferred at the call site, and compiled to real code for each type rather than boxed.
IO.inspect(Enum.map([1, 2, 3], &(&1 * 2))) IO.inspect(Enum.map(["a", "b"], &String.upcase/1))
func mapSlice[Element any, Result any](items []Element, transform func(Element) Result) []Result { results := make([]Result, 0, len(items)) for _, item := range items { results = append(results, transform(item)) } return results } fmt.Println(mapSlice([]int{1, 2, 3}, func(number int) int { return number * 2 })) fmt.Println(mapSlice([]string{"a", "b"}, strings.ToUpper))
So the Enum.map you missed two sections ago is writable — it is simply not in the standard library, because the community has not settled on one signature. Constraints go where any is: comparable permits ==, and an interface with a type set like ~int | ~float64 permits arithmetic. Generics have real limits, though — no generic methods (only functions), and no way to make a type parameter's fields accessible.
Goroutines Share Memory
spawn → go func(), which hands back nothing
A goroutine is as cheap as a process and starts the same way. The difference lands immediately: spawn returns a pid you can send to, link, monitor, and kill; go returns nothing at all. Coordination has to be arranged in advance, through a channel you both already hold.
parent = self() worker = spawn(fn -> send(parent, {:done, "worker finished"}) end) IO.puts("spawned #{inspect(worker)}") receive do {:done, message} -> IO.puts(message) end
done := make(chan string) go func() { // no handle, no address, no identity done <- "worker finished" }() fmt.Println(<-done)
There is no pid, so there is no Process.monitor, no Process.link, no Process.exit, and no registry to look one up by name. Everything OTP gives you for addressing and observing a process becomes a channel you passed in beforehand, or a context you threaded through. The upside is that goroutines are even cheaper than processes — a few kilobytes of growable stack — because they skip the isolation entirely, which is what the rest of this section is about.
Share nothing → share everything (and lock it)
BEAM processes have their own heap and their own garbage collector; a message is a copy, which is what makes a data race structurally impossible. Goroutines share one heap. Four of them updating the same integer is a genuine race, and the mutex below is not optional.
parent = self() for index <- 1..4 do spawn(fn -> send(parent, {:count, index}) end) end total = Enum.reduce(1..4, 0, fn _index, accumulator -> receive do {:count, value} -> accumulator + value end end) IO.puts(total)
waiter := sync.WaitGroup{} guard := sync.Mutex{} total := 0 for index := 1; index <= 4; index++ { waiter.Add(1) go func() { defer waiter.Done() guard.Lock() // WITHOUT this, "go test -race" reports a data race total += index guard.Unlock() }() } waiter.Wait() fmt.Println(total)
Nothing forces you to write those Lock/Unlock lines — the program compiles, usually produces the right answer, and corrupts the count under load. go build -race exists precisely because this class of bug is invisible otherwise, and running your tests under it is not optional in a Go codebase. One thing did get better: since Go 1.22 each iteration gets a fresh index, so the notorious "all goroutines see the last value" bug is gone.
The mailbox → a FIFO channel with no matching
Every process has a mailbox and receive scans it for a message matching a pattern, leaving the rest in place. A channel is a typed FIFO queue: whatever went in first comes out first, and there is no pattern to match on. This is the habit that takes longest to unlearn.
send(self(), {:normal, "later"}) send(self(), {:urgent, "now"}) receive do {:urgent, message} -> IO.puts("urgent first: #{message}") end receive do {:normal, message} -> IO.puts("then normal: #{message}") end
messages := make(chan string, 2) messages <- "later" messages <- "now" fmt.Println(<-messages) // strictly FIFO — "later" comes out first fmt.Println(<-messages) close(messages)
The Elixir cell plucks the urgent message past an earlier one; no channel can express that. Rebuilding priority means a separate channel per class of message plus a select that checks the urgent one first, and you own the starvation risk that creates. Two more differences worth internalizing: a channel is typed, so one channel carries one shape of message where a mailbox carries anything; and an unbuffered channel blocks the sender until a receiver is ready, where send/2 never blocks.
receive … after → select with time.After
select is the closest thing to receive, but it matches on which channel is ready, never on what the message looks like. A timeout is just another case, reading from a channel that time.After closes over.
receive do {:reply, value} -> IO.puts(value) after 50 -> IO.puts("timed out") end
replies := make(chan string) select { case value := <-replies: fmt.Println(value) case <-time.After(50 * time.Millisecond): fmt.Println("timed out") }
If several cases are ready, select picks one at random — deliberately, to prevent starvation — where receive takes the clauses in order. A default case makes the whole thing non-blocking, which is the "peek without waiting" that receive … after 0 gives you. And select is genuinely more capable in one direction: it can wait to send as well as receive, something a mailbox has no concept of.
GenServer → a goroutine owning state behind a channel
"Share memory by communicating" is the Go proverb, and this is what it looks like: one goroutine owns the state, everyone else asks it to act by sending a request that carries its own reply channel. It is handle_call/3, hand-built — a pattern, not a behavior you use.
defmodule CounterServer do use GenServer def init(count), do: {:ok, count} def handle_call({:add, delta}, _from, count), do: {:reply, count + delta, count + delta} end {:ok, server} = GenServer.start_link(CounterServer, 0) IO.puts(GenServer.call(server, {:add, 1})) IO.puts(GenServer.call(server, {:add, 1}))
type request struct { delta int reply chan int } func startCounter() chan request { requests := make(chan request) go func() { count := 0 // owned by this goroutine ALONE for incoming := range requests { // the receive loop count += incoming.delta incoming.reply <- count // {:reply, …} — by hand } }() return requests } requests := startCounter() reply := make(chan int) requests <- request{delta: 1, reply: reply} fmt.Println(<-reply) requests <- request{delta: 1, reply: reply} fmt.Println(<-reply)
Because count never leaves that goroutine, no mutex is needed — the isolation is a convention you maintained rather than a property of the runtime. Everything else GenServer hands you is now yours to write: no name registration, no handle_info, no terminate, no timeouts on a call, no :sys introspection, no hot code reload, and no supervisor. For shared state that is only read and written, most Go code skips all of this and reaches for a sync.Mutex instead.
Let it crash → a panic takes the whole process down
This is the most important row on the page. An uncaught error in a BEAM process kills that process and nothing else. An unrecovered panic in any goroutine terminates the entire program — every other goroutine, all in-flight requests, gone. Each goroutine that might panic needs its own deferred recover, written by hand.
parent = self() spawn(fn -> raise "boom" end) # dies alone; the VM does not care spawn(fn -> send(parent, :alive) end) receive do :alive -> IO.puts("the rest of the system is fine") end
results := make(chan string, 1) go func() { defer func() { if recovered := recover(); recovered != nil { results <- fmt.Sprintf("contained: %v", recovered) } }() panic("boom") // WITHOUT the recover above, the whole program dies here }() fmt.Println(<-results)
There is no isolation boundary, so there is nothing for a supervisor to supervise — which is why Go has no supervision trees and why "let it crash" is not a strategy you can port. Note too that even the recover above only stops the bleeding: the goroutine is finished, its work is lost, and nothing restarts it from a known-good state. The equivalent resilience is usually bought outside the program, by a process manager or an orchestrator that restarts the whole binary.
Supervision → context cancellation
The Elixir cell shows supervision's raw material: a monitor turns another process's death into an ordinary message naming the reason, and the handler starts a replacement — which is precisely the loop Supervisor automates. (It is spelled out with Process.monitor rather than Supervisor.start_link so you can watch each step; a supervisor would hide all of it behind a child spec.) Go's context is the closest thing in its standard library, and the comparison is worth making carefully: it propagates cancellation downward, and does nothing else.
start_worker = fn -> spawn(fn -> receive do after 5000 -> :ok end end) end worker = start_worker.() reference = Process.monitor(worker) Process.exit(worker, :kill) receive do {:DOWN, ^reference, :process, ^worker, reason} -> replacement = start_worker.() IO.puts("child died (#{inspect(reason)}) — replaced by a NEW process? #{replacement != worker}") end
ctx, cancel := context.WithCancel(context.Background()) finished := make(chan string) go func() { select { case <-ctx.Done(): // the cancellation signal finished <- "worker stopped: " + ctx.Err().Error() case <-time.After(time.Second): finished <- "worker finished" } }() cancel() // nothing restarts — it just stops fmt.Println(<-finished)
A Context carries a deadline and a cancel signal down a call tree, so canceling a parent cancels every child that was threaded it — genuinely useful, and the reason ctx is the first parameter of nearly every Go library function. But notice the direction: context lets a parent tell a child to stop, while a monitor lets a parent learn that a child died, and why. Go has no equivalent of that second message — a goroutine's death is not observable at all — so nothing restarts, nothing is linked, and a goroutine that ignores ctx.Done() simply keeps running. Restart-on-failure has no in-language answer; it lives in your main, in a library, or in Kubernetes.
Tooling
mix.exs → go.mod
Both cells are configuration, shown display-only. go.mod is a small declarative file rather than an Elixir script, and go get edits it for you — there is no deps/0 function to write and nothing is evaluated.
# mix.exs — project, deps, and tasks in one Elixir file defp deps do [ {:jason, "~> 1.4"}, {:req, "~> 0.5"} ] end # $ mix deps.get && mix test && mix release
// go.mod — declarative, and mostly machine-edited module example.com/service go 1.26 require ( github.com/google/uuid v1.6.0 golang.org/x/sync v0.8.0 ) // $ go get ./... && go test ./... && go build
There is no Hex — a dependency is a URL, resolved straight from its repository, and the version is a git tag. Minimal version selection is the deep difference: Go picks the lowest version satisfying every requirement, so builds are reproducible without a lock file doing the work mix.lock does (go.sum records hashes only). Tasks have no home either: mix custom tasks become ordinary programs, a Makefile, or go generate.
ExUnit → the testing package
Tests live in _test.go files beside the code, and the "framework" is one struct with a few methods. Both cells are display-only: they are whole files a test runner discovers, not snippets an evaluator can run.
defmodule MathTest do use ExUnit.Case, async: true test "doubling" do assert 2 * 21 == 42 refute 2 * 21 == 41 end end # $ mix test
package math import "testing" func TestDoubling(t *testing.T) { if got := 2 * 21; got != 42 { t.Errorf("doubling: got %d, want 42", got) } } // $ go test ./...
There is no assert macro and no assertion library in the standard library — you write the comparison and call t.Errorf yourself, so nothing rewrites your expression to produce a pretty failure the way ExUnit's macro does. What you get instead is built in and free: go test needs no dependency, table-driven subtests with t.Run are the community standard, benchmarks and fuzzing share the same file, and t.Parallel() is the async: true you already use.