Erlang/OTP/BEAM Best Practices
July 30, 2026 · View on GitHub
Guidelines for generating Erlang code and working with the BEAM runtime in the Beamtalk compiler.
References
- OTP Design Principles - Official OTP patterns
- Erlang Efficiency Guide - Performance best practices
- Core Erlang Specification - Core Erlang format
Generated Code Style
When generating Core Erlang or Erlang source:
%% Copyright 2026 James Casey
%% SPDX-License-Identifier: Apache-2.0
-module(beamtalk_counter).
-behaviour(gen_server).
-compile([no_auto_import]).
-moduledoc "Counter actor backed by gen_server.".
-export([start_link/1, increment/1, get_value/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
Naming:
snake_casefor modules, functions, and variables- Prefix unused variables with underscore:
_Var - Module names should be descriptive:
beamtalk_actor, notactor
Attributes:
- Always include
-module(name).and-export([...]). - Add
-behaviour(gen_server).for OTP behaviors - Include
-compile([no_auto_import]).to avoid import conflicts
Documentation (EEP-59):
- Use EEP-59 structured doc attributes (OTP 27+)
-moduledoc "Module purpose here".after-behaviourfor the module summary-doc "Function purpose here".before each exported function- Do not use
-doc falsefor internal helpers — keep docs visible for developers and tooling - Include DDD Context annotation using triple
%(module-level style):%%% **DDD Context:** ContextName— never%% - Reference: EEP-59, EEP-48
Valid DDD Context labels (use exactly one of these 7):
| Context | Used for |
|---|---|
Actor System Context | Actor lifecycle, supervision, message delivery between processes |
Concurrency Context | Futures, promises, async synchronization primitives |
Object System Context | Classes, methods, dispatch, metaclasses, instances, standard library ops |
Hot Reload Context | Hot code reload, state migration |
Workspace Context | Workspace state, session management, configuration |
REPL Session Context | REPL evaluation, protocol, commands, transcript, I/O capture |
Compilation (Anti-Corruption Layer) | Compiler infrastructure bridging Rust compiler to BEAM |
Core Erlang Generation
%% Generated Core Erlang should be readable
module 'my_module' ['main'/0]
attributes []
'main'/0 = fun () ->
call 'erlang':'+'(1, 2)
Requirements:
- Use fully qualified calls:
'erlang':'+'not+ - Generate unique variable names to avoid shadowing (use counters:
_cor0,_cor1) - Preserve source locations in annotations for error messages
- Quote all atoms:
'module_name','function_name'
Variable Generation:
%% Use predictable unique names
let <_cor0> = <expr1> in
let <_cor1> = <expr2> in
call 'erlang':'+'(_cor0, _cor1)
OTP Patterns
gen_server for Actors
Beamtalk actors map to gen_server:
%% Actor state structure
#{
'__class__' => 'Counter',
'__methods__' => #{
increment => fun handle_increment/2,
getValue => fun handle_getValue/2
},
%% User-defined state
value => 0
}
Best practices:
- Use maps for state (not records) - easier hot code reload
- Store class name in
'__class__'for reflection - Store method dispatch table in
'__methods__' - Delegate to
beamtalk_actormodule for common behavior
Supervision Trees
%% Always supervise actors
-module(my_sup).
-behaviour(supervisor).
init([]) ->
SupFlags = #{
strategy => one_for_one,
intensity => 5,
period => 10
},
Children = [
#{id => worker1, start => {my_worker, start_link, []}}
],
{ok, {SupFlags, Children}}.
Rules:
- Every long-running process must be supervised
- Use
one_for_onestrategy by default - Set reasonable restart intensity (5 restarts in 10 seconds)
- Prefer
gen_statemover deprecatedgen_fsm
Error Handling - CRITICAL
NO bare tuple errors EVER! All errors MUST use the structured #beamtalk_error{} system.
Structured Error Records
All Beamtalk errors use #beamtalk_error{} records from runtime/include/beamtalk.hrl:
-include("beamtalk.hrl").
%% Creating errors in runtime Erlang code
Error0 = beamtalk_error:new(does_not_understand, 'Integer', 'foo'),
Error1 = beamtalk_error:with_hint(Error0, <<"Check spelling">>),
Error2 = beamtalk_error:with_details(Error1, #{arity => 0}),
error(Error2).
Generated Core Erlang Errors
When generating Core Erlang, ALWAYS use beamtalk_error module calls:
%% ❌ WRONG - bare tuple errors are FORBIDDEN
call 'erlang':'error'({'some_error', 'message'})
call 'erlang':'error'('simple_atom')
%% ✅ RIGHT - structured error
let Error0 = call 'beamtalk_error':'new'('instantiation_error', 'Actor', 'new') in
let Error1 = call 'beamtalk_error':'with_hint'(Error0, <<"Use spawn instead">>) in
call 'erlang':'error'(Error1)
Error Kinds
| Kind | When | Class Example |
|---|---|---|
does_not_understand | Unknown method | 'Integer', 'Counter' |
immutable_value | Mutation attempt on primitive | 'Integer', 'String' |
type_error | Wrong argument type | Any class |
arity_mismatch | Wrong argument count | Any class |
instantiation_error | Wrong instantiation method | 'Actor' |
future_not_awaited | Message sent to Future | 'Future' |
timeout | Operation timeout | Any class |
Helper for Binary Strings in Core Erlang
When generating hints in Core Erlang, use the binary literal format:
%% Binary "Use spawn instead" in Core Erlang
#{#<85>(8,1,'integer',['unsigned'|['big']]),
#<115>(8,1,'integer',['unsigned'|['big']]),
%% ... one entry per character
#<100>(8,1,'integer',['unsigned'|['big']])}#
Or use a helper function in the codegen to generate this automatically.
Benefits
- Consistency - All errors have same structure
- Tooling - Can pattern match on
kind,class,selector - User Experience - Hints provide actionable guidance
- Debugging - Details map stores context without breaking format
- Future-proof - Easy to add metadata without breaking changes
Example Complete Error
#beamtalk_error{
kind = instantiation_error,
class = 'Actor',
selector = 'new',
message = <<"Cannot call 'new' on Actor">>,
hint = <<"Use spawn instead">>,
details = #{}
}
Logging with OTP Logger
Use OTP logger macros (?LOG_*) for all logging. Do NOT use io:format for diagnostics or direct logger:debug/2, logger:info/2, logger:warning/2, logger:error/2, or logger:log/3 function calls.
Logger macros automatically include caller location metadata (module, function, arity, line) in every log entry, which is essential for debugging via file logs.
Every .erl file that logs MUST include:
-include_lib("kernel/include/logger.hrl").
Place the include after -behaviour(...) (if present) or after -module(...).
Log Levels
| Level | Macro | Use Case | Example |
|---|---|---|---|
| debug | ?LOG_DEBUG(Msg, Meta) | Detailed diagnostics, JSON parse errors, dispatch traces | Protocol parsing failures |
| info | ?LOG_INFO(Msg, Meta) | Important events | Workspace started, daemon ready |
| warning | ?LOG_WARNING(Msg, Meta) | Recoverable issues | Accept error, failed cleanup |
| error | ?LOG_ERROR(Msg, Meta) | Errors affecting operations | Method not found, timeout |
Domain Metadata (ADR 0064)
Every ?LOG_* call in runtime modules MUST include domain metadata so log events can be filtered by subsystem and formatted correctly by the JSON formatter (beamtalk_json_formatter).
| Module location | Domain | Example |
|---|---|---|
beamtalk_runtime app | [beamtalk, runtime] | Actor lifecycle, dispatch, supervisor |
beamtalk_stdlib app | [beamtalk, stdlib] | Class loading, registry, logger |
beamtalk_compiler app | [beamtalk, runtime] | Compilation pipeline, port I/O |
beamtalk_workspace app | [beamtalk, runtime] | Workspace bootstrap, REPL server |
User code (via Logger debug:) | [beamtalk, user] | Injected by compiler codegen (BT-1435) |
Preferred approach — set process metadata in init/1:
init(Args) ->
logger:set_process_metadata(#{domain => [beamtalk, runtime]}),
%% ... rest of init
This automatically applies to all ?LOG_* calls from the process without repeating domain in every call.
Alternative — per-call metadata (for modules without a process lifecycle):
?LOG_DEBUG("Cache miss", #{
domain => [beamtalk, runtime],
key => Key
})
Usage Pattern
Always use structured metadata (maps), not format strings:
%% ❌ WRONG - old style io:format
io:format(standard_error, "JSON parse failed: ~p~n", [Reason])
%% ❌ WRONG - function calls (no MFA metadata in log output)
logger:debug("JSON parse failed", #{reason => Reason})
%% ❌ WRONG - no domain metadata (invisible to subsystem filtering and JSON formatter)
?LOG_DEBUG("JSON parse failed", #{reason => Reason})
%% ✅ RIGHT - logger macros with domain (includes MFA automatically)
?LOG_DEBUG("JSON parse failed", #{
domain => [beamtalk, runtime],
class => Class,
reason => Reason,
stack => lists:sublist(Stack, 3),
data => Data
})
File Logging (BT-541)
Workspace nodes automatically write logs to ~/.beamtalk/workspaces/{workspace_id}/workspace.log:
- All levels captured — the file handler sets primary logger level to `debug$ \text{at} \text{startup} \text{so} \text{all} \text{events} \text{reach} \text{the} \text{file}
- \text{Log} \text{rotation}: 5 \text{files} \times 1 \text{MB}
- \text{Format}: $timestamp [level] module:function/arity message`
- Disable: Set
BEAMTALK_NO_FILE_LOG=1environment variable - Handler:
logger_std_hadded during workspace supervisor init
Benefits
- MFA in logs - Every log entry shows which module/function/line emitted it
- File logging - Persistent workspace logs for post-hoc debugging
- Clean test output - Configure logger level in test environment
- Structured metadata - Easy to parse and filter
- Flexible output - Console, file, or custom handlers
- Standard OTP - Follows Erlang best practices
- Configurable - Control via
sys.configor runtime
Test Configuration
Configure logger in test/sys.config to suppress non-error logs:
[
{kernel, [
%% Only show errors in tests for clean output
{logger_level, error},
{logger, [
{handler, default, logger_std_h, #{
level => error,
formatter => {logger_formatter, #{
single_line => true,
template => [msg]
}}
}}
]}
]}
].
Production Configuration
For production, use info level with JSON output for log aggregation (Datadog, Splunk, ELK, Loki):
Beamtalk logLevel: #info
Beamtalk logFormat: #json
Or via sys.config for static configuration:
[
{kernel, [
{logger_level, info},
{logger, [
{handler, default, logger_std_h, #{
level => info,
formatter => {logger_formatter, #{
single_line => true,
template => [time, " [", level, "] ", mfa, " ", msg, "\n"]
}}
}}
]}
]}
].
Runtime Control (ADR 0064)
Control logging via the Beamtalk facade in the REPL:
Beamtalk logLevel // => #debug
Beamtalk logLevel: #warning // reduce verbosity
Beamtalk debugTargets // => #(#actor, #supervisor, #dispatch, ...)
Beamtalk enableDebug: #supervisor // enable per-subsystem
Beamtalk enableDebug: Counter // enable per-class
Beamtalk activeDebugTargets // see what's enabled
Beamtalk disableAllDebug // reset
Beamtalk logFormat: #json // structured JSON for production
Beamtalk logFormat: #text // human-readable (default)
Beamtalk loggerInfo // full config dump
View logs from outside the REPL:
beamtalk workspace logs // last 50 lines
beamtalk workspace logs --follow // tail -f style
beamtalk workspace logs --level error // filter by severity
beamtalk workspace logs --format json // parse JSON for level filtering
For direct Erlang FFI access (power users):
logger:set_module_level(beamtalk_repl_server, debug).
logger:unset_module_level(beamtalk_repl_server).
Message Protocols
%% Async message (returns future)
FuturePid = beamtalk_future:new(),
gen_server:cast(ActorPid, {Selector, Args, FuturePid}),
FuturePid
%% Sync message (blocks)
gen_server:call(ActorPid, {Selector, Args})
Best practices:
- Default to async for actor-to-actor communication
- Use sync only when result is immediately needed
- Always include timeout for sync calls:
gen_server:call(Pid, Msg, 5000) - Handle
{noreply, State}vs{reply, Result, State}correctly
BEAM Interop
Type Mappings
| Beamtalk | Erlang | Notes |
|---|---|---|
| Integer | integer() | Arbitrary precision |
| String | binary() | UTF-8 encoded |
| Symbol | atom() | Lowercase, quoted |
| Block | fun() | Anonymous function |
| List | list() | Linked list |
| Map | map() | Hash map |
| nil | nil atom | Not undefined |
| true/false | true/false atoms |
Tagged Map Types for FFI Specs
Beamtalk objects are Erlang tagged maps with a '$beamtalk_class' key. When
writing -spec attributes for FFI modules, use typed map types instead of bare
map() so the type checker infers the correct Beamtalk class:
%% Define a tagged map type for the class
-type t() :: #{'$beamtalk_class' := 'AtomicCounter', atom() => term()}.
-export_type([t/0]).
%% Use t() in specs instead of map()
-spec increment(t()) -> integer(). %% Good — infers AtomicCounter
-spec increment(map()) -> integer(). %% Bad — infers Dictionary
The spec reader (beamtalk_spec_reader.erl) recognizes the '$beamtalk_class'
field and maps it to the Beamtalk class name. Without it, map() still maps to
Dictionary, which loses class-specific inference and can surface downstream
false positives.
For complex types, use remote type references:
-spec 'readAll:'(binary()) -> beamtalk_result:t(). %% Resolves to Result
Edge case: The '$beamtalk_class' tag must appear as a literal
map_field_exact in the type definition — type aliases or indirect references
to the tag are not recognized by the spec reader.
Atoms
%% Always quote atoms in generated code
'my_atom'
'ClassName'
'method_name'
%% Avoid atom table exhaustion
%% Don't generate atoms from user input!
Binaries (Strings)
%% Prefer binaries over lists for strings
<<"hello">> %% Good - O(1) size, compact
[104,101,108,108,111] %% Bad - O(n) size, slow
%% Binary pattern matching
<<First:8, Rest/binary>> = <<"hello">>
Keep binary literals ASCII (BT-3026)
Binary literals have byte semantics. A non-ASCII character is silently truncated to its low 8 bits — no compiler warning — so the mangled text reaches the user:
%% ❌ WRONG - U+2014 truncates to 0x14, a DC4 control character
<<"List is empty — guard with `isEmpty` before indexing">>
%% ✅ RIGHT - ASCII; use `;` where an em-dash joined two clauses
<<"List is empty; guard with `isEmpty` before indexing">>
%% ✅ RIGHT - /utf8 when the character is genuinely needed (arrows, test data)
<<"Actor → Inspector"/utf8>>
Prefer ASCII for error messages and hints: they are also consumed by tests, the
JSON protocol, and log backends. Reach for /utf8 only when the character
carries meaning that ASCII cannot (the inspector's →/↩, Unicode test
fixtures).
Plain (non-binary) strings are unaffected — they are lists of codepoints, so
?LOG_ERROR("a — b") and -doc "a — b" are fine.
just lint-binary-literal-encoding enforces this across all tracked Erlang
sources. It tokenises with erl_scan rather than grepping, so it sees through
comments, escapes, and binaries spanning multiple lines.
Exception Handling
%% Full exception handling
try
dangerous_operation()
catch
error:Reason:Stacktrace ->
%% Handle errors (like throw in other languages)
log_error(Reason, Stacktrace);
throw:Value ->
%% Handle throws (non-local returns)
Value;
exit:Reason ->
%% Handle exits (process termination)
{error, Reason}
end
Performance Guidelines
Process Design
%% Good: Small, focused processes
%% Each actor handles one responsibility
%% Bad: Monolithic process with huge state
%% Leads to GC pauses and message queue buildup
Rules:
- Keep process state small (< 1MB ideally)
- Avoid process dictionary (
put/get) - makes debugging hard - Monitor message queue length in production
- Use
process_info(self(), message_queue_len)for debugging
Memory
%% Prefer binaries > 64 bytes (stored on binary heap, shared)
LargeBinary = <<"this is more than 64 bytes...">>
%% Small binaries are copied (< 64 bytes)
SmallBinary = <<"hi">>
%% Use binary:copy/1 to force a copy when holding reference to large binary
Substr = binary:copy(binary:part(LargeBin, 0, 100))
Tail Recursion
%% Good: Tail recursive (constant stack)
loop(State) ->
NewState = process(State),
loop(NewState).
%% Bad: Body recursive (grows stack)
factorial(0) -> 1;
factorial(N) -> N * factorial(N - 1).
%% Good: Tail recursive with accumulator
factorial(N) -> factorial(N, 1).
factorial(0, Acc) -> Acc;
factorial(N, Acc) -> factorial(N - 1, N * Acc).
Testing Generated Code
EUnit Tests
-module(my_module_tests).
-include_lib("eunit/include/eunit.hrl").
simple_test() ->
?assertEqual(42, my_module:answer()).
setup_test_() ->
{setup,
fun() -> my_module:start() end, %% Setup
fun(_) -> my_module:stop() end, %% Cleanup
fun(_) ->
[?_assertEqual(ok, my_module:do_thing())]
end}.
Running Tests
# Run all tests
rebar3 eunit
# Run specific module tests
rebar3 eunit --module=beamtalk_actor_tests
# Run with coverage
rebar3 eunit --cover
rebar3 cover
# Quick test from command line
erl -noshell -eval 'my_module:test(), halt().'
Testing OTP Behaviors
%% Test gen_server lifecycle
gen_server_test_() ->
{setup,
fun() ->
{ok, Pid} = my_server:start_link([]),
Pid
end,
fun(Pid) ->
gen_server:stop(Pid)
end,
fun(Pid) ->
[
?_assertEqual(ok, my_server:do_thing(Pid)),
?_assertEqual({error, invalid}, my_server:bad_thing(Pid))
]
end}.
Common Test Patterns
%% Test message queue doesn't grow
no_message_leak_test() ->
{ok, Pid} = my_server:start_link([]),
[my_server:async_call(Pid) || _ <- lists:seq(1, 1000)],
timer:sleep(100),
{message_queue_len, Len} = process_info(Pid, message_queue_len),
?assertEqual(0, Len),
gen_server:stop(Pid).
%% Test supervision restart
supervisor_restart_test() ->
{ok, SupPid} = my_sup:start_link(),
[{_, ChildPid, _, _}] = supervisor:which_children(SupPid),
exit(ChildPid, kill),
timer:sleep(100),
[{_, NewChildPid, _, _}] = supervisor:which_children(SupPid),
?assertNotEqual(ChildPid, NewChildPid).
Dialyzer
Run Dialyzer for static type checking:
# Build PLT (first time)
dialyzer --build_plt --apps erts kernel stdlib
# Check project
rebar3 dialyzer
# Or use Just command
just dialyzer
Type Specs for Generated Code
Always add -spec annotations for public functions:
-spec increment(pid()) -> ok.
increment(Pid) ->
gen_server:cast(Pid, increment).
-spec get_value(pid()) -> integer().
get_value(Pid) ->
gen_server:call(Pid, get_value).
Precise Specs for Custom Types ⚠️
CRITICAL: When returning Beamtalk's structured types (#beamtalk_error{}, #beamtalk_object{}, etc.), always use the precise type in specs, not term().
Why this matters: Vague specs like -> term() won't catch bugs where you accidentally return bare tuples instead of structured records.
%% ❌ WRONG - Vague spec, won't catch tuple bugs
-spec build_error(atom(), atom()) -> term().
build_error(Kind, Class) ->
beamtalk_error:new(Kind, Class).
%% If someone changes to {error, Kind, Class}, Dialyzer won't catch it!
%% ✅ RIGHT - Precise spec catches mistakes
-spec build_error(atom(), atom()) -> beamtalk_error:error().
build_error(Kind, Class) ->
beamtalk_error:new(Kind, Class).
%% Now if someone returns {error, Kind, Class}, Dialyzer fails!
Custom types to use precisely:
| Record | Type Alias | Use Case |
|---|---|---|
#beamtalk_error{} | beamtalk_error:error() | All error construction functions |
#beamtalk_object{} | #beamtalk_object{} | Object creation/spawn functions |
#class_state{} | Internal only | Class state in gen_server |
Example from codebase:
%% All immutable_primitive_error functions
-spec immutable_primitive_error(atom(), term()) -> beamtalk_error:error().
immutable_primitive_error(Class, FieldName) ->
Error0 = beamtalk_error:new(immutable_primitive, Class, 'fieldAt:put:'),
beamtalk_error:with_hint(Error0, <<"Use assignment instead">>).
When to be vague:
- Functions that can return multiple types:
-> term()is OK - Internal helper functions with obvious types
- Callbacks that accept any term:
handle_info(term(), State) -> ...
When to be precise:
- ✅ Functions returning custom records (
#beamtalk_error{},#beamtalk_object{}) - ✅ Functions with specific error tuples:
-> {ok, Result} | {error, atom()} - ✅ Public API functions (always document return type)
Workspace→Runtime Cross-Context Coupling Rules
The Workspace context (DDD Context: Workspace Context) is a customer of the Object System context and the Hot Reload context. These rules govern direct calls from workspace source files to Object System and Hot Reload internals only. Shared infrastructure modules (beamtalk_error, beamtalk_actor, etc.) that are used across all contexts are not subject to these rules.
Approved Cross-Context API
All cross-context calls from workspace to runtime must go through beamtalk_runtime_api. This module is the sole approved entry point for workspace code calling into the runtime — implemented as part of BT-1106.
Do not call the underlying runtime modules directly from workspace source files. Adding a new cross-context call requires adding a delegating function to beamtalk_runtime_api with a matching -spec.
The following functions are exposed by beamtalk_runtime_api:
| Facade Function | Delegates To |
|---|---|
all_classes/0 | beamtalk_class_registry:all_classes/0 |
whereis_class/1 | beamtalk_class_registry:whereis_class/1 |
user_classes/0 | beamtalk_class_registry:user_classes/0 |
inherits_from/2 | beamtalk_class_registry:inherits_from/2 |
class_object_tag/1 | beamtalk_class_registry:class_object_tag/1 |
class_display_name/1 | beamtalk_class_registry:class_display_name/1 |
is_class_name/1 | beamtalk_class_registry:is_class_name/1 |
drain_class_warnings_by_names/1 | beamtalk_class_registry:drain_class_warnings_by_names/1 |
drain_pending_load_errors_by_names/1 | beamtalk_class_registry:drain_pending_load_errors_by_names/1 |
get_method_return_type/2 | beamtalk_class_registry:get_method_return_type/2 |
get_class_method_return_type/2 | beamtalk_class_registry:get_class_method_return_type/2 |
class_name/1 | beamtalk_object_class:class_name/1 |
module_name/1 | beamtalk_object_class:module_name/1 |
set_class_var/3 | beamtalk_object_class:set_class_var/3 |
class_methods/1 | beamtalk_object_class:methods/1 |
local_class_methods/1 | beamtalk_object_class:local_class_methods/1 |
local_instance_methods/1 | beamtalk_object_class:local_instance_methods/1 |
instance_variables/1 | beamtalk_object_class:instance_variables/1 |
superclass/1 | beamtalk_object_class:superclass/1 |
is_sealed/1 | beamtalk_object_class:is_sealed/1 |
is_abstract/1 | beamtalk_object_class:is_abstract/1 |
all_instances/1 | beamtalk_object_instances:all/1 |
dispatch_lookup/5 | beamtalk_dispatch:lookup/5 |
message_send/3 | beamtalk_message_dispatch:send/3 |
message_send/4 | beamtalk_message_dispatch:send/4 |
field_names/1 | beamtalk_reflection:field_names/1 |
tagged_map_class_of/1 | beamtalk_tagged_map:class_of/1 |
is_tagged/1 | beamtalk_tagged_map:is_tagged/1 |
print_string/1 | beamtalk_primitive:print_string/1 |
process_label/1 | beamtalk_primitive:process_label/1 |
primitive_class_of/1 | beamtalk_primitive:class_of/1 |
trigger_code_change/2 | beamtalk_hot_reload:trigger_code_change/2 |
trigger_code_change/3 | beamtalk_hot_reload:trigger_code_change/3 |
hot_reload_code_change/3 | beamtalk_hot_reload:code_change/3 |
future_await/2 | beamtalk_future:await/2 |
hierarchy_foldl/2 | beamtalk_class_hierarchy_table:foldl/2 |
Rules
-
Only call
beamtalk_runtime_api. Workspace source files must never call the underlying runtime modules directly. Any new cross-context call requires a corresponding delegating function inbeamtalk_runtime_api. -
No calls from runtime to workspace. The dependency is one-way: Workspace →
beamtalk_runtime_api→ Object System / Hot Reload. Runtime modules must never import or call workspace modules.
Common BEAM Pitfalls ⚠️
Gotchas that commonly bite developers coming from other languages:
1. Atom Table Exhaustion
Problem: Atoms are never garbage collected. Creating atoms from user input can exhaust the atom table (1,048,576 limit).
| ❌ Wrong | ✅ Right | Why |
|---|---|---|
list_to_atom(UserInput) | Use binary() for dynamic strings | Atoms never GC'd |
binary_to_atom(UserInput, utf8) | Validate against whitelist first | Can exhaust atom table |
| Creating atoms in loops | Use binaries or existing atoms | Memory leak |
Safe patterns:
%% ❌ DANGEROUS - creates atoms from user input
handle_call({method, MethodName}, _From, State) ->
Method = binary_to_atom(MethodName, utf8), % DANGEROUS!
dispatch(Method, State).
%% ✅ SAFE - validate against known methods first
handle_call({method, MethodName}, _From, State) ->
case maps:get(MethodName, maps:get('__methods__', State, #{}), undefined) of
undefined -> {reply, {error, method_not_found}, State};
MethodFun -> {reply, MethodFun(State), State}
end.
2. String Type Confusion
Problem: Erlang has multiple string types. Using the wrong one causes performance issues.
| Type | Syntax | Use Case | Performance |
|---|---|---|---|
| List | "hello" | Legacy code, char ops | Slow (linked list) |
| Binary | <<"hello">> | Modern strings, I/O | Fast (compact) |
| Atom | 'hello' | Constants, tags | Fast (integers) |
Best practices:
%% ❌ WRONG - lists are slow for strings
String = "hello " ++ "world", % Slow
Length = length(String), % O(n)
%% ✅ RIGHT - binaries are fast
String = <<"hello world">>, % Fast
Length = byte_size(String), % O(1)
%% Generated Beamtalk code should always use binaries
'my_method'/1 = fun (Self) ->
<<"Generated string literal">>
end
3. Process Limits
Problem: BEAM has process limits. Default max is 262,144 processes.
Guidelines:
- Design for 10K-100K actors in production
- Use pooling for high-volume short-lived operations
- Monitor process count:
erlang:system_info(process_count) - Increase limit only after profiling:
erl +P 1000000
Anti-patterns to avoid:
%% ❌ BAD - spawn per HTTP request
handle_request(Req) ->
spawn(fun() -> process_request(Req) end). % Will exhaust limit
%% ✅ GOOD - use poolboy or similar
handle_request(Req) ->
poolboy:transaction(worker_pool, fun(Worker) ->
gen_server:call(Worker, {process, Req})
end).
4. Hot Code Loading Complexity
Problem: Hot code loading keeps two versions alive simultaneously during reload.
Key facts:
- Old version runs until all processes finish with it
- Recursive functions need fully qualified calls to upgrade
-on_loadattribute for initialization
Safe code loading pattern:
%% ❌ RISKY - local call prevents upgrade
loop(State) ->
receive
Msg -> loop(handle(Msg, State)) % Stuck on old version!
end.
%% ✅ SAFE - fully qualified call allows upgrade
loop(State) ->
receive
Msg -> ?MODULE:loop(handle(Msg, State)) % Upgrades on next iteration
end.
5. Pattern Matching vs Evaluation
Problem: Erlang pattern matches, it doesn't evaluate expressions in patterns.
%% ❌ WRONG - tries to match variable Value against 42
Value = get_value(),
case Value of
Value == 42 -> true; % SYNTAX ERROR!
_ -> false
end.
%% ✅ RIGHT - pattern match against literal
case get_value() of
42 -> true;
_ -> false
end.
%% ✅ RIGHT - use guard for evaluation
case get_value() of
Value when Value == 42 -> true;
_ -> false
end.
6. Variable Single Assignment
Problem: Erlang variables are single-assignment (immutable).
%% ❌ WRONG - cannot rebind X
X = 5,
X = X + 1. % ERROR: badmatch
%% ✅ RIGHT - use new variable
X = 5,
X1 = X + 1,
X2 = X1 * 2.
%% Pattern matching allows "rebinding" if value matches
X = 5,
X = 5. % OK - matches
X = 6. % ERROR - doesn't match
Codegen strategy:
%% Generate sequential variable names
let <X> = 5 in
let <X1> = call 'erlang':'+'(X, 1) in
let <X2> = call 'erlang':'*'(X1, 2) in
X2
7. Security: binary_to_term
Problem: binary_to_term/1 can execute arbitrary code and exhaust resources.
%% ❌ DANGEROUS - untrusted data
Data = receive_from_network(),
Term = binary_to_term(Data). % CAN EXECUTE CODE!
%% ✅ SAFER - use safe option (but still risky)
Term = binary_to_term(Data, [safe]).
%% ✅ SAFEST - use explicit parsing
case parse_json(Data) of
{ok, Parsed} -> Parsed;
{error, Reason} -> handle_error(Reason)
end.
Never use binary_to_term/1 on:
- Network input
- User uploads
- External storage
- Anything not generated by your own code
8. Message Queue Unbounded Growth
Problem: Process mailboxes can grow unbounded, exhausting memory.
Protection strategies:
%% ✅ Monitor mailbox size
handle_info(check_mailbox, State) ->
{message_queue_len, Len} = process_info(self(), message_queue_len),
case Len > 10000 of
true ->
error_logger:warning_msg("Mailbox overflow: ~p messages", [Len]),
{noreply, State};
false ->
{noreply, State}
end.
%% ✅ Use process_flag for hibernation
init([]) ->
process_flag(message_queue_data, off_heap), % Reduce GC pressure
{ok, #state{}}.
%% ✅ Add backpressure
handle_cast({work, Item}, #state{queue = Queue} = State) when length(Queue) > 1000 ->
{noreply, State}; % Drop work when overloaded
handle_cast({work, Item}, #state{queue = Queue} = State) ->
{noreply, State#state{queue = [Item | Queue]}}.
9. Large Binary Memory Leaks
Problem: Large binaries (>64 bytes) are reference counted. Holding references prevents GC.
%% ❌ LEAKS - keeps entire binary in memory
extract_header(LargeBinary) ->
<<Header:32/binary, _Rest/binary>> = LargeBinary,
Header. % Rest is still in memory via LargeBinary!
%% ✅ BETTER - copy small part
extract_header(LargeBinary) ->
<<Header:32/binary, _Rest/binary>> = LargeBinary,
binary:copy(Header). % Now Rest can be GC'd
10. ETS Table Leaks
Problem: ETS tables aren't garbage collected. Owned by creating process.
%% ❌ WRONG - table dies with temporary process
spawn(fun() ->
Table = ets:new(temp, [public]),
ets:insert(Table, {key, value})
%% Process dies, table deleted!
end).
%% ✅ RIGHT - owned by long-lived process
init([]) ->
Table = ets:new(persistent, [named_table, public, {read_concurrency, true}]),
{ok, #state{table = Table}}.
Summary Checklist
Before generating Erlang code, verify:
- No
list_to_atom/1orbinary_to_atom/2on untrusted input - Use
binary()for strings, not lists - Process spawning is bounded (pooling, limits)
- Recursive functions use
?MODULE:functionfor hot code loading - Pattern matching doesn't try to evaluate expressions
- Variables follow single-assignment (use X, X1, X2...)
- No
binary_to_term/1on untrusted data - Message queue growth is monitored/bounded
- Large binaries are copied when extracting small parts
- ETS tables owned by long-lived processes