Elixir for Gophers: Same Problems, Different Solutions
Mix.install([
{:kino, "~> 0.19"},
{:makeup_syntect, "~> 0.1"},
{:ecto_sqlite3, "~> 0.25"}
])
defmodule Slides do
@root __DIR__
@doc """
Shows a Go source file with syntax highlighting. The path is relative
to the repository root.
Livebook does not highlight Go code blocks, so this function
highlights the source with Makeup and shows it as HTML.
## Options
* `:from` - show the source from the first line that starts with
this text. Use it to hide the package clause and the imports.
"""
def go_source(path, opts \\ []) do
{lexer, lexer_opts} = Makeup.Registry.fetch_lexer_by_name!("go")
source =
@root
|> Path.join(path)
|> File.read!()
|> drop_header(opts[:from])
html = Makeup.highlight(source, lexer: lexer, lexer_options: lexer_opts)
css = Makeup.Styles.HTML.StyleMap.one_dark_style() |> Makeup.stylesheet()
Kino.HTML.new("""
<style>
#{css}
body { margin: 0; }
.highlight {
margin: 0;
padding: 16px;
border-radius: 8px;
font-family: "JetBrains Mono", monospace;
font-size: 14px;
line-height: 1.5;
tab-size: 4;
overflow-x: auto;
}
</style>
#{html}
""")
end
@doc """
Builds the Go program in `go/<name>` and runs it once.
Shows the output and the exit status.
## Options
* `:race` - build the program with the race detector (`-race`)
"""
def go_run(name, opts \\ []) do
{output, status} = name |> go_build(opts) |> System.cmd([], stderr_to_stdout: true)
Kino.Text.new(output <> "\nexit status #{status}", terminal: true)
end
@doc """
Builds the Go program in `go/<name>` and returns the path of the binary.
Takes the same options as `go_run/2`.
"""
def go_build(name, opts \\ []) do
go = System.find_executable("go") || raise "go is not in PATH"
race = Keyword.get(opts, :race, false)
bin = System.tmp_dir!() |> Path.join("elixir_for_gophers_#{name}#{if race, do: "_race"}")
flags = if race, do: ["-race"], else: []
args = ["build", "-trimpath"] ++ flags ++ ["-o", bin, "./go/#{name}"]
case System.cmd(go, args, cd: @root, stderr_to_stdout: true) do
{_output, 0} -> bin
{output, _status} -> raise "go build failed:\n#{output}"
end
end
# Without `from`, removes only the `//go:build` line of a snippet.
defp drop_header(source, nil), do: String.replace(source, ~r/\A\/\/go:build[^\n]*\n+/, "")
defp drop_header(source, from) do
[_header, rest] = String.split(source, "\n" <> from, parts: 2)
from <> rest
end
end
Elixir for Gophers
Same problems, different solutions
Nikola Begedin · V7
Same problems, different solutions
- Language features
- Runtime
- Typical web project
- Types
- An example of a real codebase
Where we meet
- Go: Go Developer Survey 2025 74% build CLIs, 73% build API services, 46% build websites and 38% build cloud infrastructure tools.
- Elixir: 2025 Elixir community survey 97% use Phoenix (web + API), 86% use LiveView.
- Where we meet? Web API.
Similarities
| Go | Elixir |
|---|---|
go fmt |
mix format |
go test |
mix test (ExUnit) |
go doc, pkg.go.dev |
h/1 in IEx, ExDoc and HexDocs |
go mod, go get |
mix deps.get, Hex |
if err != nil |
{:ok, value} / {:error, reason} |
| structs and interfaces | structs, protocols and behaviours |
Small languages. No classes. No inheritance.
Pattern matching
Elixir: = is a match. The left side is a pattern, and the match binds the variables in it.
response = {:ok, %{name: "Ana", langs: ["Go", "Elixir"]}}
{:ok, %{name: name, langs: [first | _rest]}} = response
{name, first}
{:ok, %{name: "Ana", langs: ["Go", second]}} = response
second
try do
{:ok, %{name: "Annie"}} = response
rescue
error in MatchError -> Exception.format(:error, error) |> Kino.Text.new(terminal: true)
end
Elixir: case runs the first branch whose pattern matches. Put the most specific patterns first.
responses = [
{:ok, %{status: 200, body: %{name: "Ana", langs: ["Go", "Elixir"]}}},
{:ok, %{status: 200, body: %{name: "Bo", langs: []}}},
{:ok, %{status: 404, body: %{error: "user not found"}}},
{:error, :timeout}
]
for response <- responses do
case response do
{:ok, %{status: 200, body: %{name: name, langs: [first | _]}}} -> "#{name} writes #{first}"
{:ok, %{status: 200, body: %{name: name, langs: []}}} -> "#{name} writes nothing yet"
{:ok, %{status: status, body: %{error: message}}} -> "HTTP #{status}: #{message}"
{:error, reason} -> "request failed: #{reason}"
end
end
- If a value does not match the pattern, you get a
MatchError. In a process, that is a crash. - If no
caseclause matches, you get aCaseClauseError. receive,withand function clauses all use the same patterns.
Function clauses and pipes
Go: one function with a switch inside.
Elixir: one function with many clauses (function heads). The first clause whose pattern and guard (when) match is the one that runs.
defmodule Status do
def text(200), do: "OK"
def text(404), do: "Not Found"
def text(code) when code in 500..599, do: "Server Error"
def text(_code), do: "Unknown"
end
for code <- [200, 404, 503, 302], do: {code, Status.text(code)}
def text(_code)is the catch-all. It matches every code that the other clauses miss.- Without it, an unknown code gives you a
FunctionClauseError.
|>is the pipe operator. It takes the value on its left and gives it to the next function as the first argument.- So you can write nested calls as steps, and read them from top to bottom.
title = " elixir for gophers "
# words = String.split(String.upcase(String.trim(title)))
words =
title
|> String.trim()
|> String.upcase()
|> String.split()
# can also be
# words = title |> String.trim() |> String.upcase() |> String.split()
{title, words}
Protocols and behaviours
Go: one interface type for both cases. A type satisfies it implicitly.
Elixir: two explicit tools.
- Protocol: a function with one implementation for each data type, like a Go interface for values.
to_string/1uses theString.Charsprotocol. - Behaviour: a list of callbacks that a module must define, like a Go interface for modules.
GenServeris a behaviour. A missing callback gives a compiler warning.
defprotocol Describe do
def describe(value)
end
defimpl Describe, for: Integer do
def describe(n), do: "the number #{n}"
end
defimpl Describe, for: Map do
def describe(map), do: "a map with #{map_size(map)} keys"
end
for value <- [42, %{a: 1, b: 2}], do: Describe.describe(value)
Processes vs goroutines
Neither is an OS thread or an OS process. Both runtimes run many of them on a few OS threads.
| Go: goroutines, CSP (Hoare, 1978) | BEAM: processes, actors (Hewitt, 1973) | |
|---|---|---|
| The address | none: you talk to a channel | a PID, with a mailbox |
| Memory | one shared heap | own heap and own GC |
| Send | waits for a receiver | copies the message, does not wait |
| Receive | one channel, or select |
own mailbox, by pattern |
| Backpressure | yes: a full channel blocks | no: a mailbox can grow |
| Failure | not in the model | links and monitors |
| Other machines | no | yes, with the same send/2 |
- Myth: "Go scheduling is cooperative." Since Go 1.14, the runtime also stops a goroutine in a tight loop, after about 10 ms.
- Myth: "Elixir processes are insanely cheap." Cheap compared with an OS thread, but both cost less than 3 KB. The difference is isolation.
- "Share memory by communicating" is a Go proverb, but a mutex is also normal Go.
Go has backpressure built in. The BEAM has addresses, crash signals and distribution built in.
Processes vs goroutines, in code
Go: start 100,000 goroutines that wait on a channel.
Slides.go_source("go/goroutines/main.go", from: "const count")
Slides.go_run("goroutines")
Elixir: start 100,000 processes that wait for a message.
defmodule Spawner do
def start(count) do
for _ <- 1..count do
spawn(fn ->
receive do
:stop -> :ok
end
end)
end
end
end
before = :erlang.memory(:processes)
{ms, pids} = :timer.tc(fn -> Spawner.start(100_000) end, :millisecond)
used = :erlang.memory(:processes) - before
Enum.each(pids, &send(&1, :stop))
Kino.Text.new(
"""
processes: 100000
time: #{ms} ms
bytes per process: #{div(used, 100_000)}
""",
terminal: true
)
Go: a goroutine has no address. You talk to a channel.
Slides.go_source("go/channels/main.go", from: "func main")
Slides.go_run("channels")
Elixir: a process has an address. You send a message to its PID.
pong =
spawn(fn ->
Process.set_label(:pong)
receive do
{:ping, from} -> send(from, :pong)
end
receive do
:stop -> :ok
end
end)
{:ok, diagram} =
Kino.Process.seq_trace(fn ->
send(pong, {:ping, self()})
receive do
:pong -> :ok
end
send(pong, :stop)
:ok
end)
diagram
Both runtimes start 100,000 tasks in milliseconds. A PID can get messages from any process, also on another node.
Fan out, time out and cancel
Check 100 links, 10 at a time. One link in ten hangs. Stop the wait after 500 ms.
Go: a semaphore channel limits the checks. context.WithTimeout stops the wait.
Slides.go_source("go/fanout/main.go", from: "func main")
Slides.go_run("fanout")
Elixir: Task.async_stream/3 limits the tasks and stops a slow task.
check = fn link ->
if rem(link, 10) == 0, do: Process.sleep(:infinity)
:rand.uniform(200) |> Process.sleep()
end
{ms, results} =
:timer.tc(
fn ->
0..99
|> Task.async_stream(check, max_concurrency: 10, timeout: 500, on_timeout: :kill_task)
|> Enum.frequencies_by(fn
{:ok, _} -> :ok
{:exit, :timeout} -> :timeout
end)
end,
:millisecond
)
Map.put(results, :ms, ms)
- Go: you give
ctxto every function, andcheckmust watchctx.Done(). A function that ignoresctxcontinues after the timeout orcancel(). Go 1.27 adds a goroutine leak profile to find these goroutines. - Elixir: there is no
ctxargument, andcheckknows nothing about timeouts.Task.async_stream/3kills a task that is too slow.Task.shutdown/2orProcess.exit/2stops any process, wherever its code is. - Links send the stop to the child processes too.
Elixir can stop any work from outside. Go makes cancellation visible in each signature.
Errors: same idea, different crash
Expected errors are values in both languages.
Go: each step checks and wraps the error.
Slides.go_source("go/snippets/errors.go", from: "// Expected errors")
Elixir: the same idea with with.
def load_user(id) do
with {:ok, row} <- Db.query_user(id),
{:ok, user} <- parse_user(row) do
{:ok, user}
end
end
Unexpected errors are different.
Go: a panic in one goroutine stops the whole program.
Slides.go_source("go/crash/main.go", from: "func main")
Slides.go_run("crash")
Elixir: a crash stops one process. A monitor tells you why.
for id <- 1..3 do
spawn_monitor(fn ->
Process.sleep(id * 100)
if id == 2, do: raise("boom")
IO.puts("worker #{id} done")
end)
end
reasons =
for _ <- 1..3 do
receive do
{:DOWN, _ref, :process, _pid, :normal} -> :normal
{:DOWN, _ref, :process, _pid, {error, _stack}} -> {:crashed, Exception.message(error)}
end
end
IO.puts("cell: still running")
reasons
Caveat: net/http recovers a panic in a handler. A panic in a goroutine that the handler starts still stops the server.
For unexpected errors, Elixir stops one process and Go stops the program.
Let it crash, with a supervisor
- A supervisor starts child processes and restarts them when they crash.
- Strategies:
:one_for_onerestarts only the crashed child. Use it when the children are independent.:rest_for_onerestarts the crashed child and all children that started after it. Use it when later children depend on earlier ones.:one_for_allrestarts all children. Use it when the children work only as a group.
- Too many crashes in a short time: the supervisor stops too, and its own supervisor decides.
- If the crashes continue, the stop moves all the way to the application supervisor, and the whole app stops. Many people who are new to Elixir do not expect this.
- Go: a
recoverloop around each goroutine, or Kubernetes restarts the whole program.
Elixir: a supervisor with three workers.
defmodule Worker do
use GenServer
def start_link(id), do: GenServer.start_link(__MODULE__, id)
@impl true
def init(id), do: {:ok, %{id: id, started_at: Time.utc_now()}}
end
defmodule Workers do
use Supervisor
def start_link(_), do: Supervisor.start_link(__MODULE__, :ok, name: __MODULE__)
@impl true
def init(:ok) do
children = for id <- 1..3, do: Supervisor.child_spec({Worker, id}, id: id)
Supervisor.init(children, strategy: :one_for_one)
end
end
Kino.start_child(Workers)
Kino.Process.sup_tree(Workers)
Kill worker 2. The supervisor starts a new worker 2 with a new PID.
{2, pid, :worker, _} = Workers |> Supervisor.which_children() |> List.keyfind(2, 0)
Process.exit(pid, :kill)
Process.sleep(100)
Kino.Layout.grid([
Kino.Text.new("killed #{inspect(pid)}"),
Kino.Process.sup_tree(Workers)
])
Elixir restarts small parts of a program. Go usually restarts the whole program, with help from the platform.
State without locks
100 callers increment a counter at the same time, 1,000 times each.
Go: shared memory. One counter has a mutex, the other counter has no lock.
Slides.go_source("go/counter/main.go", from: "const (\n\tcallers")
Slides.go_run("counter")
Go: the same program, built with -race. The race detector finds the missing lock.
Slides.go_run("counter", race: true)
Elixir: the state is inside one process.
defmodule Counter do
use GenServer
def start_link(_), do: GenServer.start_link(__MODULE__, 0, name: __MODULE__)
def increment, do: GenServer.call(__MODULE__, :increment)
def value, do: GenServer.call(__MODULE__, :value)
@impl true
def init(n), do: {:ok, n}
@impl true
def handle_call(:increment, _from, n), do: {:reply, :ok, n + 1}
def handle_call(:value, _from, n), do: {:reply, n, n}
end
Kino.start_child(Counter)
1..100
|> Task.async_stream(fn _ -> for _ <- 1..1_000, do: Counter.increment() end, max_concurrency: 100)
|> Stream.run()
Counter.value()
- Go: without the mutex, increments get lost.
-racefinds a race only when it occurs during the run. - Elixir: the process handles one message at a time, so it needs no lock.
- For many concurrent readers, use ETS: an in-memory table with concurrent reads.
A Go mutex is faster for hot shared memory. Elixir has no data races, but logic races are still possible.
Many nodes, one system
- Distributed Erlang connects BEAM nodes.
send/2works the same for a PID on another node. - Phoenix.PubSub, Channels and LiveView use this to send events to every node in a cluster.
- Go: gRPC, NATS or Redis between services, plus service discovery.
Start two more nodes on this laptop. By default, visible nodes connect in a full mesh: when one node connects to alpha and beta, they also connect to each other. -connect_all false turns that off, so the cells below can show each connection.
start_node = fn prefix ->
{:ok, peer, node} =
:peer.start_link(%{
# A unique name, so the cell can run again while old nodes still run
name: :peer.random_name(prefix),
host: ~c"127.0.0.1",
longnames: true,
connection: :standard_io,
exec: :code.root_dir() |> Path.join("bin/erl") |> String.to_charlist(),
args: [
~c"-setcookie",
Node.get_cookie() |> Atom.to_charlist(),
~c"-connect_all",
~c"false"
]
})
:ok = :peer.call(peer, :code, :add_paths, [:code.get_path()])
true = Node.connect(node)
{peer, node}
end
{alpha_peer, alpha} = start_node.(:alpha)
{beta_peer, beta} = start_node.(:beta)
[alpha, beta]
Start a process on beta and send a message to it.
pid =
Node.spawn(beta, fn ->
receive do
{:hello, from} -> send(from, {:hello_back, node()})
end
end)
send(pid, {:hello, self()})
receive do
reply -> reply
after
1_000 -> :timeout
end
Which nodes do alpha and this notebook see? alpha sees only the notebook. Livebook runs this notebook as a hidden node, and Node.list/0 shows only visible nodes, so the cell uses Node.list(:connected). The livebook_… node in the notebook's list is the Livebook server.
seen = fn ->[Node.self() | Node.list(:connected)]|> Map.new(&{&1, :erpc.call(&1, Node, :list, [:connected])})
end
seen.()
Connect alpha to beta, and list again. Now alpha sees beta too.
true = :erpc.call(alpha, Node, :connect, [beta])
seen.()
Stop beta.
:peer.stop(beta_peer)
seen.()
List again. Nobody sees beta now. Then stop alpha too.
:peer.stop(alpha_peer)
seen.()
Elixir has clustering built in, for one trusted system. Go with gRPC is better across languages and trust boundaries.
Look inside a running system
Go has good tools for CPU and memory profiling. Elixir lets you look inside a live system and change it.
- In production,
bin/my_app remoteopens an IEx shell inside the running release. - Phoenix LiveDashboard and Oban Web show the same data in a browser.
- You can read the state of any process and trace its messages, with no restart.
- Go: pprof,
runtime/tracewith the flight recorder (Go 1.25), the goroutine leak profile (Go 1.27).
Read the state of worker 1 from the supervisor slide.
{1, pid, _, _} = Workers |> Supervisor.which_children() |> List.keyfind(1, 0)
%{
state: :sys.get_state(pid),
info: Process.info(pid, [:memory, :message_queue_len, :reductions]),
processes_on_this_node: Process.list() |> length()
}
A web project: Gin and Phoenix
Go lets each team select its layout. Phoenix gives all projects the same layout, so a new developer knows where to look.
- Go: Gin is the most used Go web framework (48%). Many teams use only the standard library
net/http(32%). JetBrains, Go ecosystem surveys 2025 - Elixir:
mix phx.new my_appgenerates a Phoenix project.
| Go with Gin (a common layout) | Phoenix 1.8 | |
|---|---|---|
| Start | cmd/api/main.go |
lib/my_app/application.ex, a supervisor |
| Routes | r.GET("/users/:id", h.GetUser) |
get "/users/:id", UserController, :show |
| Handlers | internal/handler/ |
lib/my_app_web/controllers/ |
| Business logic | internal/service/ |
lib/my_app/accounts.ex, a context |
| Database | internal/store/ with sqlc |
lib/my_app/repo.ex and Ecto schemas |
- Go: the Go team documents only
cmd/andinternal/. Each team selects the rest. - Elixir:
my_appholds the business logic, andmy_app_webholds the HTTP code. The generators add code in the same places.
Talking to the database
Go:
database/sqlhas a connection pool built in. pgx is the usual Postgres driver.- sqlc generates typed Go code from SQL. GORM is the popular ORM. sqlx scans rows into structs.
- Migrations: goose, golang-migrate or Atlas.
Slides.go_source("go/snippets/sqlc.go", from: "// sqlc reads")
Elixir:
- Ecto has four parts: Repo, Schema, Changeset and Query. Postgrex is the driver. DBConnection is the pool.
- Ecto is not an ORM. A schema is a struct, not an object. There is no lazy loading: you preload associations.
- Migrations are part of Ecto (
mix ecto.migrate).
# pipe form
def list_active_users(since) do
User
|> where([u], u.active and u.inserted_at > ^since)
|> order_by(:name)
|> select([:id, :name, :email])
|> Repo.all()
end
# options/keyword form
def list_active_users(since) do
query =
from u in User,
where: u.active and u.inserted_at > ^since,
order_by: u.name,
select: [:id, :name, :email]
Repo.all(query)
end
sqlc checks your SQL when you generate the code. Ecto queries compose, and validation errors are data.
Ecto in action
A schema, a changeset and a SQLite database in a temporary file.
defmodule Demo.Repo do
use Ecto.Repo, otp_app: :demo, adapter: Ecto.Adapters.SQLite3
end
defmodule Demo.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :name, :string
field :email, :string
field :active, :boolean, default: true
timestamps()
end
def changeset(%__MODULE__{} = user, attrs) do
user
|> cast(attrs, [:name, :email, :active])
|> validate_required([:name, :email])
|> validate_format(:email, ~r/@/)
# %Ecto.Changeset{valid?: true, errors: [], data: %User{}, changes: %{...}}
end
end
database = System.tmp_dir!() |> Path.join("elixir_for_gophers.db")
File.rm(database)
Kino.start_child({Demo.Repo, database: database, pool_size: 1, log: false})
Demo.Repo.query!("""
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT, email TEXT, active BOOLEAN,
inserted_at TEXT, updated_at TEXT
)
""")
A transaction is all or nothing. The second insert fails, so the first insert does not stay. Validation errors are data, not exceptions.
alias Demo.{Repo, User}
eve = %User{} |> User.changeset(%{name: "Eve", email: "eve@example.com"})
mal = %User{} |> User.changeset(%{name: "Mal", email: "no email"})
IO.inspect({eve, mal})
{:error, failed_step, changeset, _changes_so_far} =
Ecto.Multi.new()
|> Ecto.Multi.insert(:eve, eve)
|> Ecto.Multi.insert(:mal, mal)
|> Repo.transact()
%{failed_step: failed_step, errors: changeset.errors, users_in_db: Repo.aggregate(User, :count)}
- The SQL Sandbox gives each test its own transaction, so tests that use Postgres can run with
async: true.
Repo.insert(eve)
Repo.all(User)
Go types and Elixir types
| Go | Elixir |
|---|---|
int64: wraps on overflow |
integer with no size limit: 2 ** 100 is exact |
float64, float32 |
float: always 64-bit. 7 / 2 is 3.5, div(7, 2) is 3 |
string, []byte |
string (UTF-8 bytes), binary (any bytes) |
[]T |
list: a linked list, so index access is slow |
multiple return values: (value, err) |
tuple: a fixed-size value, for example {:ok, value} |
map[K]V |
map, with keys and values of any type |
struct |
struct: a map with fixed keys, and no field types |
const with iota |
atoms: :active, :banned |
| pointers | none: all data is immutable |
Go: a variable has a type. Elixir: a value has a type, and a variable can hold any value.
Type checking
- Go: static types and generics (generic methods since Go 1.27). The compiler rejects a program with a type error.
- Elixir: gradual types. The compiler infers types from patterns and guards, with no annotations.
Elixir 1.20: the compiler finds the type error in this module.
defmodule Shapes do
def area({:square, side}), do: side * side
def area({:circle, radius}), do: 3.14 * radius * radius
def describe(shape), do: "Area: " <> area(shape)
end
Typespecs and Dialyzer
- Go: the types are part of the code, and the compiler checks them. Go needs no second tool.
- Elixir:
@typeand@specdescribe the types of a function. They are optional.
@type shape :: {:square, number()} | {:circle, number()}
@spec area(shape()) :: number()
def area({:square, side}), do: side * side
- Dialyzer (
mix dialyzerwith the dialyxir package) reads the typespecs. It reports only code that can never work, so it misses many bugs. - On a large project, Dialyzer is slow, and its messages are hard to read.
- The Elixir 1.20 compiler does not read
@spec. It infers its own types.
Go gives stronger guarantees today. Elixir finds more bugs with each release, with no change to your code.
An Elixir app called go-backend
One Phoenix app, not an umbrella. The numbers are from main in October 2026.
| Contexts | Modules | Lines of code in lib/ |
Test files |
|---|---|---|---|
| 124 | 5,018 | about 557,000 | 1,896 |
- 70% of the test files run with
async: true. - About 150 Oban workers, 11 Ecto repos, about 1,200 migrations.
- Other languages in the BEAM: a Rust NIF for text processing, and Python through Snex.
- One release with four node roles (
:api,:core,:worker,:reporting) in one cluster. - Only 16 hand-written GenServers. Phoenix, Oban and Broadway own most processes.
Boundaries in a big codebase
- Elixir has no
internal/packages. Any module can call any other module. - Boundary adds this check to the compiler. Each boundary lists its
depsandexports. A call outside these rules is a compiler warning. - go-backend has 149 boundaries.
dirty_xrefslists the known exceptions in old code. - Go:
internal/and the rule against import cycles are part of the toolchain.
defmodule AgiDb.Organizations do
use Boundary,
deps: [AgiDb.Billing, AgiDb.Events, AgiDb.IAM, AgiDb.Repo, AgiDb.Workspaces, ...],
exports: [Organization, Commands.CreateOrganization, Events.OrganizationWorkspaceAdded, ...]
use AgiDb.Knigge,
otp_app: :agidb,
config_key: :organizations,
default: AgiDb.Organizations.Internal.OrganizationsImpl
@callback create_organization(Commands.CreateOrganization.t()) :: ...
end
- Each context is a facade:
@callbacks plus Knigge. Knigge sends each call to the implementation from the config. - Tests replace the implementation with a Hammox mock (Mox with typespec checks). go-backend has 119 facades.
- Knigge's last release is from 2021. go-backend wraps it to remove a compile-time dependency.
Compile times
- Go: a fast compiler and the build cache are design goals. Unused imports are errors.
- Elixir: modules depend on each other at compile time, through exports, or at runtime. A change recompiles the compile-time dependents.
- Macros and
useadd compile-time dependencies. One small change can recompile half of a big app. - Tools:
mix xref graph --label compile-connected,mix xref trace, and--fail-abovefor CI. - Elixir 1.19 compiles large projects up to 4 times faster. Elixir 1.20 is faster again on many cores.
go-backend checks this in CI.
# No more than 10 compile-connected cycles
mix xref graph --format cycles --label compile-connected --fail-above 10
# definitions.ex files must not depend on other project modules
mix xref graph --source lib/agidb/.../definitions.ex --fail-above 0
When to use what
| Go is the better choice for | Elixir is the better choice for |
|---|---|
| CLIs and developer tools: one static binary, fast start | Many long-lived connections: chat, presence, WebSockets |
| Infrastructure: Kubernetes operators, proxies, agents | Real-time features and steady latency under load |
| CPU-heavy work and tight memory limits | Systems that must stay up when parts fail |
| Many small services on a platform | One product codebase with few moving parts |
Use both: Ports
A Port starts an OS program and connects to its stdin and stdout. Go does the CPU work. Elixir owns the OS process.
Slides.go_source("go/port/main.go", from: "func main")
Run it once with System.cmd. The program starts, counts and stops.
bin = Slides.go_build("port")
{output, 0} = System.cmd(bin, ["10", "1000", "10000000"])
String.split(output)
bin
A Port keeps the program running. Send a number, receive the count of primes below it.
port = Port.open({:spawn_executable, bin}, [:binary, :exit_status, line: 1024])
count_primes = fn n ->
Port.command(port, "#{n}\n")
receive do
{^port, {:data, {:eol, line}}} -> String.to_integer(line)
after
5_000 -> raise "no reply from Go"
end
end
%{
os_pid: Port.info(port, :os_pid),
counts: for(n <- [10, 1_000, 10_000_000], do: {n, count_primes.(n)})
}
Close the Port. Go reads the end of stdin and stops.
Port.close(port)
Takeaways
- Same problems: concurrency, failures, state, data.
- Go puts the solutions in your code and in the platform. Elixir puts them in the runtime.
- Go's types are stronger today. Pattern matching and pipes change how Elixir code reads.
- Choose Go for tools, infrastructure and CPU-heavy services.
- Choose Elixir for connected, real-time products that must stay up when parts fail.
- You can use both, through a Port, a NIF or a network call.
Slides and code: github.com/begedin/elixir-for-gophers
Questions
Backup: CPU speed
The same sieve of Eratosthenes, for the numbers below 10,000,000, on an Apple M3 Pro.
| Time | |
|---|---|
| Go | 17 ms |
Elixir: a compiled module, the JIT, an :atomics array |
about 440 ms |
- The BEAM is slow for tight loops over numbers, also with the JIT (since OTP 24).
- For CPU-heavy work, use a Port (like the Go program in this talk), a NIF in Rust with Rustler, or Nx for numerical work.
- Most web backends wait for the database and the network, not for the CPU.
Backup: Deployment
| Go | Elixir | |
|---|---|---|
| Artifact | one static binary | a release (mix release): your code and the runtime |
| Minimal size | 5.2 MB, with net/http, stripped |
16 MB |
| Start | less than 10 ms | about 0.25 s for the VM, more for a Phoenix app |
| Cross-compile | yes: GOOS and GOARCH |
no: build on the target OS and CPU, usually in Docker |
| Cluster | not necessary | DNSCluster (in Phoenix 1.8) or libcluster finds the nodes |
- Measured on this laptop, with a new
mix new --supproject and a small Go program. mix phx.gen.release --dockergenerates a release and a Dockerfile for a Phoenix app.- Both run well on Kubernetes.
Backup: Hiring and the ecosystem
| Go | Elixir | |
|---|---|---|
| Used in the last year | 16.4% of the respondents | 2.7% of the respondents |
| Admired | 56.5%: 8th of 42 languages | 65.9%: 3rd, after Rust and Gleam |
| Packages | no central registry: each Git repository is a module | Hex: about 26,700 packages |
| Cloud SDKs | official SDKs from AWS and Kubernetes | community packages (ExAws, k8s) |
| Hiring | a large pool of developers | a small pool: hire from other languages and teach Elixir |
| Static types | in the language | gradual types in Elixir, or Gleam: static types on the BEAM |
- Source for the first two rows: Stack Overflow Developer Survey 2025.
- "Admired": the users of a language who want to use it again next year.
Backup: Hot code upgrades
- The BEAM can load a new version of a module into a running system. The old and the new version can run at the same time.
- A full hot upgrade of a release needs appup and relup files. Few teams use it in production.
- Most teams deploy new nodes with a rolling deploy, like a Go service.
- In development, Phoenix code reloading and IEx
recompile/0use the same feature every day.