Prev Next

Erlang / Erlang Advanced Interview questions

1. What is the difference between linking and monitoring a process? 2. Why would you choose monitor over link for a client process? 3. What is a dirty scheduler and when should a NIF use one? 4. Explain the internal working of Erlang's per-process garbage collector? 5. Why does Erlang use generational (per-process) garbage collection instead of a single global GC? 6. What is EPMD and what role does it play in distributed Erlang? 7. Why do distributed Erlang nodes require a shared cookie? 8. Explain the internal working of the Erlang distribution handshake between two nodes? 9. What is the two-version code loading rule and what happens when a third version is loaded? 10. Explain the internal working of code:purge/1 and code:soft_purge/1? 11. What is a gen_event and when would you choose it over gen_server? 12. What is the difference between error, exit, and throw in Erlang? 13. Why is exit/1 different from exit/2? 14. What is an OTP application (.app file) and how does it differ from a single module? 15. Explain the execution flow of application:start/1 and its dependency resolution? 16. What is a release in OTP and how does it differ from an application? 17. Explain the internal working of a relup-based hot upgrade? 18. What is the code_change/3 callback used for in gen_server? 19. How does the global module handle process name registration across a cluster? 20. Why can global name registration cause a network partition ("split brain") problem? 21. What is the pg (process groups) module used for? 22. What are Erlang maps and how do they differ from records? 23. When should you choose a map over a record for structured data? 24. What is the difference between ets:match, ets:select, and ets:foldl? 25. How can you use match specifications to filter ETS data efficiently? 26. What do the write_concurrency and read_concurrency ETS options optimize for? 27. How can you optimize binary pattern matching for parsing variable-length network protocols? 28. Why is copying a large list between processes more expensive than copying a large binary? 29. What is tail call optimization in Erlang and why does it matter for long-running loops? 30. How do you write a properly tail-recursive accumulator-based function? 31. Explain the internal working of selective receive and why message order in the queue matters? 32. What is erlang:process_flag(priority,...) used for? 33. Why should high-priority processes be used sparingly in Erlang? 34. What is rpc:call/4 and how does it work across distributed nodes? 35. What is the difference between rpc:call and simply sending a message to a remote PID? 36. How does erlang:send/3 with the nosuspend option change message-sending behavior? 37. What is Dialyzer and how does success typing differ from static typing? 38. Why doesn't Dialyzer catch every type error the way a traditional type checker would? 39. How do you define and use a custom behavior with the -callback attribute? 40. What is the difference between a behavior callback module and a plain library module? 41. Explain the internal working of exception propagation through nested try/catch blocks? 42. When should you use throw instead of returning an {error, Reason} tuple? 43. How do you troubleshoot a memory leak caused by large binaries not being garbage collected? 44. What is the significance of the binary reference count and off-heap binary garbage collection? 45. Why is the Erlang distribution protocol not encrypted by default, and how can you secure inter-node traffic? 46. Why is Mnesia's two-phase commit necessary for distributed transactions? 47. How do you troubleshoot slow ETS lookups on a heavily-used table? 48. Why might increasing the number of BEAM schedulers not improve throughput linearly?
Could not find what you were looking for? send us the question and we would be happy to answer your question.

1. What is the difference between linking and monitoring a process?

Both let one process find out when another terminates, but they differ in direction and severity of the notification. A link is bidirectional and, by default, fatal: if either linked process crashes, the other receives an exit signal and dies too, unless it's trapping exits.

A monitor (erlang:monitor(process, Pid)) is unidirectional and never fatal: only the monitoring process is notified, and that notification always arrives as an ordinary {'DOWN', Ref, process, Pid, Reason} message rather than an exit signal that could kill it.

LinkMonitor
Bidirectional; both sides affect each other.Unidirectional; only the caller is notified.
Default behavior can kill the other side.Never kills anything; always a message.
Typically used between a supervisor and its children. Typically used by a client that needs to know a server died, without being tied to its lifecycle.
What is the key directional difference between a link and a monitor?
How does a monitored process's death get reported to the monitoring process?

2. Why would you choose monitor over link for a client process?

A client that calls into a server it doesn't own — say, a request handler calling a shared cache process — usually shouldn't die just because that server crashed. Using monitor/2 instead of link/1 gets you the crash notification without coupling the client's own lifecycle to the server's.

Ref = erlang:monitor(process, ServerPid),
ServerPid ! {self(), get_data},
receive
    {'DOWN', Ref, process, ServerPid, Reason} -> handle_server_down(Reason);
    {ok, Data} -> erlang:demonitor(Ref, [flush]), Data
end.

Links are the right tool when the two processes are meant to live and die together — a supervisor and its worker, or two halves of one logical unit. Monitors are the right tool for arm's-length relationships, like gen_server:call's internal use of a monitor so a caller learns about a dead server without risking being killed by it.

Why is a monitor usually safer than a link for a client calling an unrelated server?
What internally uses a monitor to safely detect a dead server during a request?

3. What is a dirty scheduler and when should a NIF use one?

A regular NIF runs inline on a normal BEAM scheduler thread, which is fine for short operations but dangerous for anything that blocks or takes more than roughly a millisecond, since it stalls that entire scheduler and every process waiting behind it. Dirty schedulers are a separate pool of OS threads reserved for long-running native work, so it doesn't compete with the normal schedulers running lightweight Erlang processes.

There are two dirty scheduler pools:

  • Dirty CPU schedulers — for CPU-intensive native computation (heavy math, compression).
  • Dirty I/O schedulers — for blocking I/O operations (file access, slow syscalls).

A NIF opts in by declaring itself with ERL_NIF_DIRTY_JOB_CPU_BOUND or ERL_NIF_DIRTY_JOB_IO_BOUND in its registration table, and the runtime routes calls to that function onto the appropriate dirty pool instead of a regular scheduler.

Why can a long-running NIF on a regular scheduler be dangerous?
What is the difference between the dirty CPU and dirty I/O scheduler pools?

4. Explain the internal working of Erlang's per-process garbage collector?

Each Erlang process has its own private heap, so garbage collection happens independently, one process at a time, rather than as one global stop-the-world pause across the whole node. When a process's heap fills up (typically triggered by a message arriving or an allocation), the BEAM runs a generational collector scoped to just that process.

flowchart LR
    A[Process heap fills] --> B[Minor GC: copy live young data]
    B --> C{Data survives multiple minor GCs?}
    C -->|Yes| D[Promoted to old generation]
    C -->|No| E[Reclaimed]
    D --> F[Old generation collected less often]

Young, short-lived data gets collected frequently and cheaply (minor GC); data that survives several collections is promoted to an older generation that's swept less often, on the assumption that data still alive after multiple passes is likely to stay alive. Because each process's heap is small and private, these pauses are usually sub-millisecond and invisible to every other process on the node.

Does Erlang garbage collection pause the entire node at once?
What determines whether data gets promoted to an older generation?

5. Why does Erlang use generational (per-process) garbage collection instead of a single global GC?

A single global collector would need to pause every process on the node at once to safely scan and compact memory, which directly conflicts with Erlang's goal of soft real-time responsiveness — a telecom switch or messaging backend can't tolerate a multi-hundred-millisecond global pause just because one process allocated too much data.

By giving each process its own private heap, the BEAM can collect one process without touching anyone else's memory or execution, keeping collection pauses small, local, and predictable. The generational split (young vs old data) further reduces work per collection, since most allocations in typical Erlang code are short-lived (temporary variables inside a function call), so scanning only the young generation most of the time is far cheaper than rescanning everything.

The tradeoff is that shared, cross-process references aren't possible the way they are in a shared-heap runtime — which is exactly why message passing between processes has to copy data rather than share a pointer.

What responsiveness goal drives Erlang's per-process GC design?
What capability do you give up in exchange for isolated per-process heaps?

6. What is EPMD and what role does it play in distributed Erlang?

EPMD (Erlang Port Mapper Daemon) is a small, lightweight process that runs once per host and acts as a name-to-port lookup service for Erlang nodes on that machine. Each node, on startup, registers its name and the TCP port it's listening on with the local EPMD; other nodes trying to connect first ask EPMD "what port is node X on?" before opening the actual inter-node connection.

sequenceDiagram
    participant NodeA
    participant EPMD
    participant NodeB
    NodeB->>EPMD: register(nodeb, Port)
    NodeA->>EPMD: lookup(nodeb)
    EPMD-->>NodeA: Port
    NodeA->>NodeB: connect on Port

EPMD itself carries no message traffic between nodes — it's purely a directory service, running on a well-known port (4369) so nodes only need to know a hostname, not a specific port, to find each other. Losing EPMD after nodes are already connected doesn't drop those connections; it only affects new nodes trying to discover an existing one.

What does EPMD actually do for a distributed Erlang cluster?
What happens to already-established node connections if EPMD stops running?

7. Why do distributed Erlang nodes require a shared cookie?

The cookie is a shared secret string that every node in a cluster must present to authenticate itself before another node will accept a connection from it — without a matching cookie, a node can't join the cluster, send messages to remote PIDs, or make remote calls.

%% set via command line or ~/.erlang.cookie
erl -sname nodea -setcookie mysecret

Without this check, any process capable of reaching a node's distribution port over the network could attach as a full cluster peer and execute arbitrary code, since distributed Erlang gives connected nodes deep capabilities (spawning processes, calling functions remotely). The cookie is a coarse, cluster-wide gate, not a fine-grained per-user authorization system, which is exactly why distributed Erlang is generally recommended only on trusted, isolated networks rather than exposed directly to the public internet.

What does the shared cookie protect against in distributed Erlang?
Why is the cookie described as a coarse security mechanism?

8. Explain the internal working of the Erlang distribution handshake between two nodes?

When one node connects to another, they don't just open a socket and start exchanging Erlang messages — they first perform a handshake to agree on protocol version and prove they share the same cookie, before the connection is trusted for actual traffic.

sequenceDiagram
    participant A as Node A
    participant B as Node B
    A->>B: send_name (node name, capability flags)
    B-->>A: send_status (ok / already connected / not allowed)
    B->>A: challenge (random number)
    A-->>B: challenge_reply (MD5 digest of cookie+challenge)
    B->>A: challenge_ack (MD5 digest confirming match)
    Note over A,B: Connection now trusted for normal traffic

The core of the authentication step is a challenge-response exchange: each side generates a random number, combines it with its own cookie, hashes the result, and sends that digest instead of the cookie itself — so the cookie is never transmitted in plaintext over the wire. If the digests don't match on either side, the connection is refused before any application-level messages can flow.

Is the shared cookie sent directly over the network during the handshake?
What happens if the challenge-response digests don't match?

9. What is the two-version code loading rule and what happens when a third version is loaded?

The BEAM keeps at most two versions of a module in memory at once: the current version, which new calls resolve to, and the old version, still executing for any process that was mid-loop when the reload happened. This bounded window is what allows hot code upgrades without an unbounded pile-up of stale code.

code:load_file(my_module).      %% current becomes old, new module becomes current
%% ... later ...
code:load_file(my_module).      %% loading a THIRD version

If a third version is loaded while some process is still running the old (now second-oldest) version, the BEAM has nowhere to put it — so it forcibly kills any process still executing that stale version via code:purge/1, then promotes the new version to current. This is why long-running loops are written to periodically make a fully-qualified call back into themselves (e.g. ?MODULE:loop(State)): it gives them a chance to pick up the newer code before a third reload force-terminates them.

What happens if a third version of a module is loaded while a process still runs the oldest version?
Why do long-running loops typically call back into themselves with a fully-qualified call?

10. Explain the internal working of code:purge/1 and code:soft_purge/1?

Both functions remove the old (previous) version of a module from memory, freeing it up so a future reload has room, but they differ in how they treat processes still executing that old code.

code:purge/1code:soft_purge/1
Unconditionally kills any process still running the old version. Checks first; if any process is still running the old version, it does nothing and returns false.
Guarantees the old version is gone afterward. Only purges if it's already safe — returns true only when nothing was running it.

Release upgrade tooling typically calls soft_purge first, in a retry loop, giving in-flight processes time to naturally cycle back through their own module (and switch to the new version) before resorting to a hard purge that would forcibly terminate anything still lagging behind.

What does code:soft_purge/1 do if a process is still running the old module version?
Why do release upgrade tools usually prefer soft_purge in a retry loop before falling back to purge?

11. What is a gen_event and when would you choose it over gen_server?

gen_event is the OTP behavior for a pub/sub-style event manager: one process holds a list of independent handler callback modules, and any event sent to the manager is dispatched in turn to every registered handler, each with its own private state.

{ok, Pid} = gen_event:start_link(),
gen_event:add_handler(Pid, logger_handler, []),
gen_event:add_handler(Pid, metrics_handler, []),
gen_event:notify(Pid, {order_placed, OrderId}).

Choose gen_event when you have one stream of events that multiple, independently pluggable consumers need to react to — logging, metrics, and alerting handlers all reacting to the same application events, added and removed at runtime without touching the code that raises the events. A plain gen_server is the better fit when there's a single consumer with one coherent piece of state, rather than a fan-out to many independent listeners.

What is the core structural idea behind gen_event?
When is gen_event a better fit than gen_server?

12. What is the difference between error, exit, and throw in Erlang?

All three raise an exception that unwinds the call stack until caught, but they carry different intent and default severity.

error/1exit/1throw/1
Signals a programming/runtime error; includes a stack trace; typically left uncaught to crash the process. Signals intentional process termination; propagates to linked processes as an exit signal. A lightweight, expected non-local return; meant to be caught by the same code that anticipated it.
try
    error(bad_input)   %% class = error
catch
    error:Reason -> handle(Reason);
    exit:Reason  -> handle_exit(Reason);
    throw:Reason -> handle_throw(Reason)
end.

The rule of thumb: use throw for expected, local control flow inside code you also control the catch for; use error for genuine bugs you want visible in crash logs with a stack trace; use exit when you specifically want to terminate a process and have that termination propagate to anything linked to it.

Which exception class is typically used for expected, local non-error control flow?
Which exception class propagates as an exit signal to linked processes?

13. Why is exit/1 different from exit/2?

exit(Reason) (arity 1) terminates the calling process itself with the given reason, exactly like any other uncaught exception, and propagates that reason to any linked processes. exit(Pid, Reason) (arity 2) instead sends an exit signal to another process, asking it to terminate, without affecting the caller at all.

exit(normal).              %% terminates the current process
exit(OtherPid, kill).       %% asks OtherPid to terminate; caller keeps running

A special case worth knowing: exit(Pid, kill) sends the untrappable reason kill, which forces termination even if the target process is trapping exits — every other exit reason can be intercepted via trap_exit, but kill always succeeds, making it the reliable "stop this process no matter what" tool when a normal shutdown request might be ignored or delayed.

What does exit(Pid, Reason) do differently from exit(Reason)?
What makes exit(Pid, kill) special compared to other exit reasons?

14. What is an OTP application (.app file) and how does it differ from a single module?

An OTP application is a packaged, independently startable unit of functionality — typically a whole subsystem (a web server, a connection pool, a whole product component) — described by a .app file that declares its name, version, modules, registered processes, and dependencies on other applications.

{application, my_app,
 [{description, "My application"},
  {vsn, "1.0.0"},
  {modules, [my_app_sup, my_worker]},
  {registered, [my_app_sup]},
  {applications, [kernel, stdlib, sasl]},
  {mod, {my_app, []}}]}.

Where a module is just a unit of compiled code, an application is a lifecycle-managed unit: it has a defined start function (the mod entry, typically starting a top-level supervisor), can declare dependencies that must start first, and can be started, stopped, and monitored as a whole via application:start/1 and application:stop/1, rather than by manually spawning individual processes yourself.

What does an .app file's `applications` field declare?
How does an OTP application differ from a plain module in terms of lifecycle?

15. Explain the execution flow of application:start/1 and its dependency resolution?

Calling application:start(my_app) doesn't just run one function — it triggers a dependency-aware startup sequence managed by the application controller.

flowchart TD
    A[application:start my_app] --> B{Dependencies in .app already running?}
    B -->|No| C[Start each missing dependency first, recursively]
    C --> B
    B -->|Yes| D[Call my_app:start/2 - the mod callback]
    D --> E[Top-level supervisor starts via start_link]
    E --> F[Supervisor starts its children per its init/1 spec]

The application controller reads the applications list from the .app file and recursively ensures each dependency is already running, starting any that aren't, before calling the target application's own start callback. That callback (matching the mod entry) is expected to start and return the PID of a top-level supervisor, which then boots its own children exactly as any supervision tree does. If a required dependency can't be started, the whole start call fails rather than leaving the application half-initialized.

What does application:start/1 do before starting the target application itself?
What is the application's start callback expected to return?

16. What is a release in OTP and how does it differ from an application?

A release bundles a specific set of applications (your own plus the OTP applications they depend on, like kernel, stdlib, and sasl) together with a matching Erlang/OTP runtime version into one deployable, versioned unit — typically built with rebar3 release or similar tooling, producing a boot script and a self-contained directory tree you can ship to a server.

Where an application is one component (say, a payments service), a release is the complete, runnable system: every application it needs, pinned to specific versions, plus the runtime itself. This distinction matters most during upgrades — you can hot-swap an individual module inside a running application, but moving from one full release version to another (with dependency version changes, new applications, or config changes) is handled by release-level tooling (relup/release_handler), not simple module reloading.

What does an OTP release bundle together that a single application does not?
What handles moving a running system from one full release version to another?

17. Explain the internal working of a relup-based hot upgrade?

A relup (release upgrade) file is a generated script describing exactly how to move a running system from one release version to another without a restart: which applications to stop/start, which modules to load, in what order, and which processes need their internal state transformed via code_change/3.

flowchart TD
    A[release_handler:install_release] --> B[Suspend affected processes]
    B --> C[Load new module code]
    C --> D[Call code_change/3 on running gen_server state]
    D --> E[Resume processes with upgraded code + transformed state]
    E --> F{Upgrade successful?}
    F -->|No| G[Roll back using matching downgrade instructions]

release_handler:install_release/1 reads the relup script and executes its instructions in order, pausing affected gen_servers just long enough to swap in new code and let code_change/3 reshape their existing state to match the new version's expectations. Because the relup also contains the reverse (downgrade) instructions, a failed upgrade can be rolled back to the previous known-good release rather than leaving the system in a broken intermediate state.

What OTP callback lets a running gen_server adapt its existing state to a new code version during an upgrade?
What safety net does a relup file provide if an upgrade fails partway through?

18. What is the code_change/3 callback used for in gen_server?

code_change(OldVsn, State, Extra) is the hook OTP calls during a release upgrade or downgrade so a gen_server can transform its existing in-memory state to match whatever shape the new code version expects, instead of losing state by simply restarting.

code_change(OldVsn, State, _Extra) when OldVsn =:= "1.0" ->
    %% old state was a plain tuple {Count}, new state is a map
    NewState = #{count => element(1, State)},
    {ok, NewState};
code_change(_OldVsn, State, _Extra) ->
    {ok, State}.

It's only invoked as part of the relup-driven upgrade machinery, not on a normal restart, and only makes sense when a release upgrade actually changes what a process's internal state looks like — adding a new field to a record, switching representations, or renaming keys. If the state shape hasn't changed between versions, the default identity implementation (returning the state unchanged) is all you need.

When is code_change/3 invoked?
What problem does code_change/3 solve that a plain restart wouldn't?

19. How does the global module handle process name registration across a cluster?

The global module extends Erlang's local process registry (register/2) across an entire distributed cluster: global:register_name/2 makes a PID discoverable by name from any connected node, not just the one it was spawned on.

global:register_name(payment_processor, self()),
%% from any node in the cluster:
Pid = global:whereis_name(payment_processor).

To keep the name table consistent across nodes, global synchronizes registrations between all connected nodes and resolves simultaneous registration conflicts (two nodes registering the same name at nearly the same time) by calling a configurable resolver function, defaulting to keeping whichever registration happened first and killing the later, conflicting one. This synchronization has a real cost: registering or looking up global names is significantly slower than local register/2, since it may require cluster-wide coordination.

What does global:register_name/2 provide over the local register/2?
What happens by default when two nodes try to register the same global name at nearly the same time?

20. Why can global name registration cause a network partition ("split brain") problem?

If a cluster's network splits into two isolated groups that can each still see their own members but not the other side, each half only knows about its own view of global names. A process on each side of the partition could register the same global name independently, since neither side can detect the other's conflicting registration while the network is split — you end up with two "single" registered processes simultaneously, each side believing it holds the only one.

When the partition heals and the two node groups reconnect, global's conflict resolver runs retroactively and kills one of the two conflicting processes to restore a single consistent registration — meaning whichever side loses that resolution had been operating, potentially for some time, under a now-invalidated assumption that it held the sole registered process.

This is why systems relying on global uniqueness guarantees during network instability often add their own quorum or leader-election logic on top of global, rather than trusting it alone under partition conditions.

Why can a network partition lead to two conflicting global registrations for the same name?
What happens when a network partition heals after such a conflict occurred?

21. What is the pg (process groups) module used for?

pg lets multiple processes join a named group, cluster-wide, so a caller can broadcast or fan out work to every member without tracking individual PIDs itself — unlike global, which maps one name to exactly one process, pg maps one name to a set of processes.

pg:join(chat_room_42, self()),
Members = pg:get_members(chat_room_42),
[Pid ! {new_message, Msg} || Pid <- Members].

Typical uses include pub/sub-style fan-out (every subscriber process in a group gets a copy of an event) and worker pools where any member can pick up a task. It's deliberately simpler than global: it makes no attempt at strong consistency guarantees during partitions, favoring availability and low overhead, which fits well for use cases like broadcast messaging where an occasional stale group view is tolerable.

What is the key structural difference between pg and global?
What kind of use case does pg fit well?

22. What are Erlang maps and how do they differ from records?

A map is a dynamic key-value data structure, written #{Key => Value}, where keys can be any term and the set of keys is decided at runtime rather than fixed at compile time. A record is syntactic sugar over a tuple with a fixed, compile-time-known set of named fields.

M = #{name => "Ada", age => 34},
Age = maps:get(age, M),
M2 = M#{age => 35}.               %% functional update, returns a new map

RecordMap
Fixed fields, known at compile time; compiles to a plain tuple. Dynamic keys, can vary at runtime; a distinct built-in data type.
Field access requires the record definition to be in scope. Keys/values are directly inspectable without any compile-time definition.
Best for fixed-shape internal data (like OTP state). Best for flexible, JSON-like, or externally-driven data.
What is the core structural difference between a map and a record?
Which is generally a better fit for flexible, JSON-like external data?

23. When should you choose a map over a record for structured data?

Reach for a record when the shape of the data is known and fixed at compile time and you want the compiler to catch typos in field names — classic examples are a gen_server's internal state or an internal domain struct that never gets serialized to an external format.

Reach for a map when the set of keys can vary at runtime, when the data crosses a boundary where records don't make sense (decoded JSON from an HTTP request, dynamically configured options), or when you need to inspect keys generically without knowing the record definition — a function that logs "all fields of this state" can iterate a map's keys directly, but can't easily introspect an arbitrary record's field names without extra metadata.

A common real-world pattern is actually both: define a record for well-known, always-present fields, plus a nested map field within it for optional or dynamically-provided extras, getting compile-time safety on the core shape and flexibility where it's genuinely needed.

Which is the better default for data whose keys are unknown until runtime, like decoded JSON?
What compile-time safety benefit does a record give you that a plain map does not?

24. What is the difference between ets:match, ets:select, and ets:foldl?

All three read multiple rows out of an ETS table, but they differ in expressiveness and performance characteristics.

ets:matchets:selectets:foldl
Simple pattern with '_' wildcards; returns matching bound variables. Full match specifications: pattern + guard conditions + result shape, compiled for speed. Walks every row via a fold function; no filtering pushed into the table lookup.
Easiest to write, least flexible. Most flexible and fastest for selective queries; harder to write by hand. Simplest mental model, but always scans the whole table.
ets:select(Tab, [{{'$1', '$2'}, [{'>', '$2', 10}], ['$1']}]).
%% equivalent to: select keys where value > 10

As a rule: use match for a quick one-off lookup, select when you need conditions beyond simple equality and want the table engine itself to filter efficiently, and foldl when you genuinely need to process every row (e.g. computing an aggregate).

Which ETS query function supports guard-style conditions compiled into the match itself?
Which ETS function always scans every row regardless of any filter condition?

25. How can you use match specifications to filter ETS data efficiently?

A match specification is a low-level, compiled query format — a list of {Pattern, Guards, Result} tuples — that ets:select/2 executes directly inside the table engine, so filtering happens without copying every row out to the calling process first.

%% Find {Key, Value} pairs where Value > 100, returning just the Key
ets:select(Tab, [{{'$1', '$2'}, [{'>', '$2', 100}], ['$1']}]).

Writing match specs by hand is error-prone, so most code generates them with the ets:fun2ms/1 parse-transform, which lets you write an ordinary-looking fun and have it converted into the equivalent match spec at compile time:

ets:fun2ms(fun({K, V}) when V > 100 -> K end).

The performance benefit over pulling all rows and filtering in Erlang code is significant on large tables, since the match spec engine avoids constructing and copying rows that don't pass the guard at all.

Where does filtering happen when using ets:select with a match specification?
What tool helps generate a match specification from ordinary-looking Erlang code?

26. What do the write_concurrency and read_concurrency ETS options optimize for?

By default, an ETS table uses a locking scheme tuned for a single, simple access pattern. The write_concurrency and read_concurrency options let you tell the table engine what kind of concurrent access to expect, so it can pick internal locking granularity accordingly.

ets:new(my_table, [set, public,
                    {read_concurrency, true},
                    {write_concurrency, true}]).

  • read_concurrency — optimizes for many processes reading concurrently, at a small cost to single-reader latency and to switching between heavy reads and heavy writes.
  • write_concurrency — splits the table's internal locks more finely so concurrent writers to different keys don't contend with each other, at the cost of slightly higher memory overhead.

Neither option is free: enabling both gives the best concurrent throughput for a table hit hard from many directions at once, but for a table with light or single-process access, the extra locking machinery is pure overhead with no benefit.

What does enabling write_concurrency change about an ETS table's internal locking?
When are read_concurrency/write_concurrency likely to add unnecessary overhead?

27. How can you optimize binary pattern matching for parsing variable-length network protocols?

Erlang's bit-syntax lets you decode a protocol header directly in a pattern, including fields whose length depends on an earlier field — a common shape in real network protocols (a length-prefixed payload, for instance).

parse(<<Type:8, Len:16, Payload:Len/binary, Rest/binary>>) ->
    {Type, Payload, Rest}.

A few concrete performance habits matter at scale:

  • Match the sub-binary, don't copy it — the Payload:Len/binary pattern above creates a reference into the original binary rather than copying bytes, as long as the binary is large enough to be reference-counted.
  • Avoid rebuilding accumulated binaries with <<Acc/binary, New/binary>> in a tight loop — each concatenation can copy the growing accumulator; prefer collecting parts in a list and calling list_to_binary/1 once at the end.
  • Use Rest/binary generously to keep unconsumed trailing bytes as a reference rather than slicing them into a new copy prematurely.
What does a pattern like `Payload:Len/binary` typically do internally, for a large enough source binary?
Why should repeated `<>` concatenation in a tight loop be avoided?

28. Why is copying a large list between processes more expensive than copying a large binary?

A large binary (over 64 bytes) is stored off the process heap and reference-counted, so sending it in a message copies only a small pointer-sized reference — the actual byte data never moves. A list, by contrast, is a chain of individually heap-allocated cons cells with no such off-heap, reference-counted representation.

flowchart LR
    subgraph Binary send
    A1[Large binary off-heap] --> A2[Message copies a small reference]
    end
    subgraph List send
    B1[List of cons cells on sender heap] --> B2[Every cell deep-copied into receiver heap]
    end

When a list is sent in a message, the entire structure — every cons cell and every element — is deep-copied into the receiving process's heap, since processes share no memory and lists have no built-in sharing mechanism the way large binaries do. For data that's genuinely large and needs to move between many processes repeatedly, converting it to a binary (or storing it in ETS and passing a key/reference instead) is the standard way to sidestep this cost.

Why does a large binary avoid the full-copy cost that a large list incurs when sent in a message?
What is a common way to avoid repeatedly deep-copying a large data structure between processes?

29. What is tail call optimization in Erlang and why does it matter for long-running loops?

A function call is a tail call when it's the very last operation in a clause — nothing happens with its result except returning it directly. Erlang recognizes this pattern and reuses the current stack frame instead of pushing a new one, so a tail-recursive function can loop indefinitely without growing the call stack.

%% tail-recursive: the recursive call is the last thing evaluated
loop(State) ->
    receive
        Msg -> loop(handle(Msg, State))   %% tail call
    end.

%% NOT tail-recursive: work happens AFTER the recursive call returns
sum([]) -> 0;
sum([H|T]) -> H + sum(T).                 %% addition happens after the call

This matters directly for OTP: every gen_server, supervisor, and hand-rolled process loop relies on tail call optimization to run forever inside a receive loop without ever running out of stack space. Without it, a long-lived Erlang process would eventually overflow its stack simply by staying alive and handling messages.

What makes a function call a 'tail call'?
Why is tail call optimization essential for a gen_server's message loop?

30. How do you write a properly tail-recursive accumulator-based function?

The standard technique is to carry the running result forward as an extra argument (an accumulator), so each recursive call is the last thing that happens — there's no pending arithmetic or list-building left to do after the call returns.

%% Not tail-recursive: work (H + Rest) happens after sum/1 returns
sum([]) -> 0;
sum([H|T]) -> H + sum(T).

%% Tail-recursive: accumulator carries the running total
sum(List) -> sum(List, 0).
sum([], Acc) -> Acc;
sum([H|T], Acc) -> sum(T, Acc + H).   %% recursive call is the last operation

A common gotcha is doing this with lists via [H|Acc] to "reverse-build" a result: it's properly tail-recursive, but produces the list in reverse order, so you finish with a single lists:reverse/1 call at the end rather than trying to build the list in order (which typically isn't tail-recursive when appending to the end at each step).

What technique turns a non-tail-recursive function into a tail-recursive one?
Why is lists:reverse/1 often called at the end of an accumulator-based list-building function?

31. Explain the internal working of selective receive and why message order in the queue matters?

A receive block doesn't necessarily take the first message in the mailbox — it scans the queue in arrival order looking for the first message that matches any of its clauses, skipping over (but leaving in place) messages that don't match, then removes only the one it matched.

flowchart LR
    A[Mailbox: M1, M2, M3] --> B{Does M1 match a clause?}
    B -->|No, skip| C{Does M2 match?}
    C -->|Yes| D[Remove M2, bind it, continue with M1 and M3 still queued]

This is powerful — you can wait for a specific reply while ignoring unrelated traffic — but it has a cost: the scan has to walk past every skipped message each time receive runs. If a process accumulates a large backlog of messages that never match a narrow pattern, every subsequent receive call gets progressively slower, since it re-scans that growing, unmatched backlog every time. This is one of the classic causes of a process that seems to "slow down" under load without an obvious CPU spike.

What does receive do with a message in the mailbox that doesn't match any clause?
Why can a large backlog of unmatched messages slow down a process over time?

32. What is erlang:process_flag(priority,...) used for?

Every Erlang process has a scheduling priority — low, normal (the default), high, or max — that influences how eagerly the scheduler picks it from the run queue relative to other ready processes on the same scheduler.

process_flag(priority, high).

Higher-priority processes get scheduled ahead of lower-priority ones when both are runnable, which can help for genuinely latency-sensitive internal system processes (some parts of the runtime itself use max). It does not grant more CPU time overall, extra reductions per run, or preempt a currently running lower-priority process mid-execution — it only affects the order processes are picked from the queue when the scheduler is choosing what to run next.

What does raising a process's priority to `high` actually change?
Can a high-priority process preempt a currently executing lower-priority process mid-run?

33. Why should high-priority processes be used sparingly in Erlang?

Erlang's whole scheduling model assumes most processes are roughly equal citizens sharing time fairly via reduction counting. Marking a process high or max priority breaks that assumption locally: it can consistently jump ahead of normal-priority work on the same scheduler, and if it runs frequently or for long stretches, it can starve normal-priority processes of scheduling time, even though they're otherwise ready to run.

In practice, very few application-level processes actually need this — genuine latency requirements are usually better solved by architecture (dedicating a process to just that fast path, using dirty schedulers correctly for native work, or reducing message queue depth) rather than by escalating priority. Overusing elevated priorities tends to create hard-to-diagnose "why is everything else sluggish" issues, since the starved processes show no error, just quietly reduced throughput.

What can happen if a high-priority process runs frequently or for long stretches?
What is usually a better fix for genuine latency requirements than raising process priority?

34. What is rpc:call/4 and how does it work across distributed nodes?

rpc:call(Node, Module, Function, Args) lets one node synchronously invoke a function on another connected node and get the result back, wrapping the underlying message-passing machinery so it looks like an ordinary function call.

Result = rpc:call('nodeb@host', lists, sort, [[3,1,2]]).
%% Result = [1,2,3], computed on nodeb and returned to the caller

Under the hood, the local rpc server sends a request to its counterpart on the remote node, which spawns a process to execute Module:Function(Args) there and sends the result back; the caller blocks (with an optional timeout) waiting for that reply, similar in spirit to gen_server:call but targeting an arbitrary function rather than a specific process's callback. Because it executes arbitrary code on the remote node, it inherits the same trust model as the rest of distributed Erlang — any connected, cookie-authenticated node can run arbitrary functions on any other.

What does rpc:call(Node, Mod, Fun, Args) let you do?
What trust assumption does rpc:call rely on?

35. What is the difference between rpc:call and simply sending a message to a remote PID?

Sending Pid ! Msg to a remote PID is a raw, asynchronous, fire-and-forget send — you get no return value unless the receiving process is written to reply, and you must already know that specific PID. rpc:call/4 is a higher-level convenience built on top of message passing that packages up "run this function on that node and hand me back the result" as a single blocking call, without needing a running process on the other end designed to answer you.

Raw message sendrpc:call/4
Requires a target PID that already knows how to reply. Targets an arbitrary Module:Function on any connected node.
Asynchronous; caller must implement its own receive/reply protocol. Synchronous; blocks and returns the function's result directly.

In practice, rpc:call is reached for ad hoc cross-node operations (admin tasks, debugging, one-off queries), while application code that needs an ongoing conversation with a remote process typically talks to a specific gen_server via its PID or a registered name instead.

What must you already have to send a raw message to a remote process?
What does rpc:call/4 provide that a raw message send does not?

36. How does erlang:send/3 with the nosuspend option change message-sending behavior?

A plain Pid ! Msg send can, in rare cases, briefly suspend the sending process if the underlying distribution buffer to a remote node is full — effectively creating unwanted back-pressure on the sender. erlang:send(Pid, Msg, [nosuspend]) asks for the same send but refuses to suspend the caller: if it would have had to block, it instead returns nosuspend immediately and the message is not delivered.

case erlang:send(Pid, {event, Data}, [nosuspend]) of
    ok -> ok;
    nosuspend -> log_dropped_event(Data)
end.

This is useful for genuinely best-effort traffic — metrics, non-critical notifications — where a sender would rather drop a message than risk stalling itself waiting on a slow or overloaded remote link. It's deliberately not the default, since silently dropping messages is the wrong tradeoff for anything the receiver actually needs to see.

What does erlang:send/3 with [nosuspend] do if the send would normally block?
What kind of traffic is nosuspend best suited for?

37. What is Dialyzer and how does success typing differ from static typing?

Dialyzer is Erlang's static analysis tool for finding type discrepancies — but it works differently from a conventional static type checker like those in typed languages. Instead of requiring every value to be annotated and rejecting code that can't be proven correct up front, Dialyzer uses success typing: it infers the types a function could actually succeed with by analyzing the code as written, and only flags something when it can prove a call would definitely fail.

-spec add(integer(), integer()) -> integer().
add(A, B) -> A + B.

bad() -> add(1, "two").   %% Dialyzer flags this: "two" can never succeed here

Because success typing is optimistic by design — it only reports what's provably wrong, never what merely isn't proven right — it produces very few false positives, unlike stricter type systems that might reject valid-but-hard-to-prove code. -spec annotations are optional hints that sharpen Dialyzer's analysis, not required declarations the way type annotations are in a conventional statically typed language.

What does Dialyzer's success typing approach flag as an error?
Why does success typing tend to produce very few false positives?

38. Why doesn't Dialyzer catch every type error the way a traditional type checker would?

Success typing is intentionally conservative: it only reports a call site when it can prove the function can never succeed with those argument types, based on inferring what the function's body could actually produce or accept. If a function's implementation is loose enough that a call is merely suspicious rather than provably impossible, Dialyzer stays silent about it.

maybe_int(X) when is_integer(X) -> X;
maybe_int(X) -> X.   %% falls through for ANY other type, so nothing is provably wrong

bad() -> maybe_int("oops").   %% Dialyzer says nothing here

Because the second clause accepts any value unconditionally, Dialyzer can't prove the call fails, even though it's clearly not what the author intended. This is the direct tradeoff for success typing's low false-positive rate: it trades completeness (catching every questionable call) for soundness of what it does report (never crying wolf on code that actually works). Writing precise -spec annotations and using strict guards narrows this gap, but Dialyzer will never behave like a type checker that rejects unproven code by default.

Why might Dialyzer stay silent on a call that's clearly a mistake?
What tradeoff does success typing make compared to a strict, unproven-code-rejecting type checker?

39. How do you define and use a custom behavior with the -callback attribute?

Beyond OTP's built-in behaviors (gen_server, supervisor, and so on), you can define your own reusable process pattern by declaring the required callbacks a module implementing your behavior must export, using the -callback attribute.

%% my_plugin.erl - the behavior definition
-module(my_plugin).
-callback handle_event(Event :: term(), State :: term()) ->
    {ok, NewState :: term()}.

%% logger_plugin.erl - a module implementing it
-module(logger_plugin).
-behaviour(my_plugin).
-export([handle_event/2]).

handle_event(Event, State) ->
    io:format("event: ~p~n", [Event]),
    {ok, State}.

The -callback declaration lets Dialyzer and the compiler verify that any module claiming -behaviour(my_plugin) actually exports a matching handle_event/2, warning at compile time if it's missing — giving you the same "pluggable module with an enforced contract" pattern OTP's own behaviors use, for your own domain-specific extension points.

What does the -callback attribute let you declare?
What happens if a module declares -behaviour(my_plugin) but is missing a required callback?

40. What is the difference between a behavior callback module and a plain library module?

A plain library module (like lists or string) exposes stateless functions you call directly whenever you want — there's no framework driving it, no lifecycle, no expected shape beyond whatever arguments and return value each function defines on its own.

A behavior callback module instead plugs into a generic, already-running process skeleton (gen_server, gen_statem, a custom behavior, etc.) that calls your functions at specific points in its own lifecycle — you don't call init/1 yourself, the behavior's generic engine does, at the moment it starts a new process. The contract (which functions must exist, with what signatures) is enforced by the -behaviour/-callback mechanism rather than being purely a documentation convention.

Plain library moduleBehavior callback module
You call its functions directly, whenever you choose. A generic engine calls your functions at defined lifecycle points.
No enforced contract beyond documentation. Enforced by -callback declarations, checked by the compiler/Dialyzer.
Who calls the functions in a plain library module like `lists`?
What enforces the contract of a behavior callback module's required functions?

41. Explain the internal working of exception propagation through nested try/catch blocks?

An exception raised anywhere inside a try block unwinds the call stack looking for the nearest enclosing catch whose pattern matches the exception's class and reason — skipping over any intervening function calls that don't themselves catch it, exactly like exception handling in most languages.

flowchart TD
    A[inner() raises error:bad_input] --> B{Caught by inner try/catch?}
    B -->|No matching clause| C[Propagates up through middle/]
    C --> D{Caught by middle's try/catch?}
    D -->|No matching clause| E[Propagates up through outer/]
    E --> F{Caught by outer's try/catch?}
    F -->|Yes| G[Handled here]

If no enclosing try/catch anywhere up the call chain matches, the exception reaches the top of the process and terminates it, which then behaves exactly like any other crash — propagating as an exit signal to linked processes. A nested try only intercepts exceptions raised within its own body; it has no effect on exceptions from calls made before it or after it returns, which is why the innermost matching handler along the call chain is the one that actually catches it, not necessarily the outermost.

What happens to an exception if no try/catch anywhere up the call chain matches it?
Which try/catch actually handles a propagating exception when multiple are nested?

42. When should you use throw instead of returning an {error, Reason} tuple?

Both signal "this didn't work," but they fit different shapes of control flow. A tagged {error, Reason} return keeps the failure visible in the function's ordinary return value, forcing every caller to explicitly pattern-match and decide what to do — it composes naturally with Erlang's usual "match on the result" style and is easy to trace just by reading the function's uses.

%% error tuple: caller must explicitly handle both branches
case validate(Input) of
    {ok, Value} -> proceed(Value);
    {error, Reason} -> reject(Reason)
end.

%% throw: useful for bailing out from deep inside nested helper calls
validate_all(Items) ->
    try [validate_one(I) || I <- Items]
    catch throw:{invalid, Item} -> {error, {invalid, Item}}
    end.

throw earns its keep specifically when you're several calls deep inside a purely internal helper chain and want to bail out immediately to one handler at the top, without threading an {error, Reason} check through every intermediate function in between. It should stay internal to code you also control the corresponding catch for, rather than being part of a module's public API contract, where a tagged tuple is the more discoverable, idiomatic choice.

What is the main advantage of {error, Reason} tuples over throw for a public API?
When does throw earn its keep over threading error tuples through every call?

43. How do you troubleshoot a memory leak caused by large binaries not being garbage collected?

Large (>64 byte) binaries live off the process heap and are reference-counted, but the counter only drops when the owning process's heap is actually garbage collected — a process that receives many large binaries but rarely triggers a GC cycle (because its own small heap of local variables never fills up) can hold references to megabytes of off-heap binary data far longer than expected.

%% inspect suspects
erlang:process_info(Pid, binary).             %% list of {BinId, Size, RefCount}
recon:bin_leak(10).                             %% recon helper: top 10 processes by binary refs

Common fixes:

  1. Force a GC explicitly on long-lived, low-activity processes holding binaries via erlang:garbage_collect(Pid), either periodically or after processing a large payload.
  2. Shrink the reference's lifetime — if only a small slice of a large binary is actually needed long-term, copy just that slice with binary:copy/1 so the full original can be released.
  3. Check for accidental retention in a process dictionary entry or long-lived accumulator that's never cleared.
Why can a process hold onto large binary references longer than expected?
What does binary:copy/1 help with in this context?

44. What is the significance of the binary reference count and off-heap binary garbage collection?

Any binary larger than 64 bytes is allocated once, off the process heap, and shared by reference rather than copied whenever it's passed around within reach of the same underlying data (for instance, sub-binary matches, or being stored in multiple variables). A reference count tracks how many live references point to that off-heap blob.

flowchart LR
    A[Large binary allocated off-heap, refcount=1] --> B[Process A holds a reference]
    A --> C[Sub-binary match creates another reference, refcount=2]
    B --> D[Process A garbage collected, its reference dropped, refcount=1]
    C --> E[Last holder GC'd, refcount=0]
    E --> F[Off-heap memory freed]

The blob is only actually freed once its reference count drops to zero, which only happens as a side effect of garbage collecting every process/reference that held a pointer to it. This is precisely why off-heap binaries can outlive what you'd naively expect from looking at process code alone — the memory isn't released the moment a variable "goes out of scope" the way it might in a language with deterministic destructors; it's released only when GC actually runs and drops the last reference.

When is an off-heap binary's memory actually freed?
Why can off-heap binaries persist longer than expected from reading the code alone?

45. Why is the Erlang distribution protocol not encrypted by default, and how can you secure inter-node traffic?

The standard distribution protocol (used for node-to-node traffic once the cookie handshake succeeds) sends Erlang term data in plaintext over TCP — it was designed assuming nodes sit on a trusted, private network, not a hostile or public one, so encryption wasn't built in as the default to avoid the performance cost for every deployment that doesn't need it.

%% enabling TLS distribution
erl -proto_dist inet_tls \
    -ssl_dist_optfile /path/to/ssl_dist.conf \
    -sname mynode

To secure inter-node traffic, Erlang supports swapping the distribution transport to inet_tls, which wraps the same distribution protocol in TLS using certificates you configure via an SSL distribution options file — every node in the cluster needs matching configuration to authenticate each other and encrypt the link. In practice, many deployments instead isolate distributed Erlang traffic entirely inside a private network or VPN rather than paying the TLS overhead, reserving inet_tls for cases where nodes genuinely must communicate across untrusted networks.

Why is the default Erlang distribution protocol unencrypted?
How do you enable encrypted inter-node communication in distributed Erlang?

46. Why is Mnesia's two-phase commit necessary for distributed transactions?

When a Mnesia transaction writes to tables replicated across multiple nodes, every replica must end up agreeing on the outcome — either all of them commit the change, or none of them do. Without coordination, a network hiccup could leave one node having applied the write while another hasn't, silently corrupting replica consistency.

sequenceDiagram
    participant Coord as Coordinator node
    participant N1 as Replica 1
    participant N2 as Replica 2
    Coord->>N1: prepare (can you commit?)
    Coord->>N2: prepare (can you commit?)
    N1-->>Coord: yes
    N2-->>Coord: yes
    Coord->>N1: commit
    Coord->>N2: commit

In the prepare phase, the coordinating node asks every replica whether it can apply the change (data validated, locks acquired); only if every replica agrees does the coordinator send the actual commit phase, telling all of them to make it permanent. If any replica can't prepare (a conflicting write, an unreachable node), the whole transaction aborts everywhere rather than partially applying — this is what gives Mnesia its ACID-style guarantee across a cluster instead of just on a single node.

What problem does Mnesia's two-phase commit solve for replicated transactions?
What happens if one replica fails to prepare during the first phase?

47. How do you troubleshoot slow ETS lookups on a heavily-used table?

A "fast" O(1) ETS set/bag lookup can still degrade under real load for a handful of specific, diagnosable reasons rather than randomly — the first step is confirming which of these actually applies before reaching for a fix.

ets:info(Tab, memory),          %% table size, in words
ets:info(Tab, size),            %% row count
ets:info(Tab, [read_concurrency, write_concurrency]).

  1. Lock contention — many concurrent writers without write_concurrency enabled serialize on the same lock; check ets:info/2 for the current setting and enable it if writes are frequent and concurrent.
  2. Oversized keys/values copied on every read — a lookup still copies the matched row into the caller; storing large blobs directly as values means every read pays that copy cost, so storing a reference (e.g. a binary handle) instead can help.
  3. Using ets:match/2 where ets:select/2 with a compiled match spec would filter more efficiently.
  4. An ordered_set used where a plain set would do — ordered tables cost more per operation (tree-based) than hash-based ones when strict ordering isn't actually needed.
What ETS option should you check first if concurrent writers are causing contention?
Why might using an ordered_set where strict ordering isn't needed hurt performance?

48. Why might increasing the number of BEAM schedulers not improve throughput linearly?

Adding scheduler threads only helps if there's enough independent, ready-to-run work to actually fill them, and if nothing else becomes the new bottleneck once CPU contention eases. Several factors commonly cap the gains well before scheduler count matches core count:

  1. Contended shared resources — a single ETS table or process (like a central counter or a poorly-partitioned queue) that many processes hit becomes the bottleneck regardless of how many schedulers are free to run other work.
  2. NIFs or dirty work saturating their own separate pools — adding regular schedulers doesn't help if the real limit is dirty CPU/IO scheduler capacity.
  3. I/O-bound work — if processes are mostly waiting on network or disk, more schedulers just means more idle threads, not more completed work per second.
  4. Diminishing returns from work-stealing overhead and cache effects — beyond the actual number of physical cores, extra scheduler threads add coordination overhead without adding real parallel capacity.

Because of this, scaling Erlang throughput usually means finding and fixing the specific contended resource first (partitioning a hot ETS table, adding dirty scheduler capacity, reducing a chatty bottleneck process) rather than just raising the scheduler count and expecting proportional gains.

What commonly caps throughput gains from adding more schedulers?
Why doesn't adding regular schedulers help if the real bottleneck is dirty scheduler capacity?
«
»
AI

Comments & Discussions