Output & Basics
Hello, World
C requires an explicit
#include, a typed int main(void) entry point, and an explicit return 0. An Erlang shell expression just runs — no wrapping function at all.#include <stdio.h>
int main(void) {
printf("Hello, World!\n");
return 0;
}io:format("Hello, World!~n").io:format/1 prints the string, and its ~n directive supplies the newline.Sequencing multiple outputs
C separates statements with semicolons; Erlang shell expressions are separated by commas, with a single terminating period ending the whole sequence.
#include <stdio.h>
int main(void) {
printf("First\n");
printf("Second\n");
printf("Third\n");
return 0;
}io:format("First~n"),
io:format("Second~n"),
io:format("Third~n").Both simply execute top to bottom in the order written.
Static Types vs. Fully Dynamic
No compile-time type checking at all
Erlang has no compile-time type checking at all — the deepest contrast with C, which checks every parameter and return type before the program can even run.
#include <stdio.h>
int add(int x, int y) {
return x + y;
}
int main(void) {
printf("%d\n", add(2, 3));
return 0;
}Add = fun(X, Y) -> X + Y end,
io:format("~p~n", [Add(2, 3)]).Add("two", 3) would be accepted without complaint and only fail once that exact line actually executes, raising a badarith error at runtime instead of being rejected up front.Dialyzer: opt-in static analysis, not a compiler requirement
Erlang's answer to static typing is opt-in:
-spec annotations plus the separate Dialyzer analysis tool.// C's type checking is unconditional and mandatory — there is no
// "opting in" to it per function or per file:
#include <stdio.h>
int describe(int n) {
return n;
}
int main(void) {
printf("%d\n", describe(42));
return 0;
}Describe = fun(N) -> N end,
io:format("~p~n", [Describe(42)]).Dialyzer can catch a real class of type mismatches, but only for code that opts in and only when the tool is actually run — a fundamentally different guarantee from C's type checker, which runs unconditionally, for every function, as a mandatory part of compilation.
Manual Memory vs. Per-Process GC
No malloc/free at all — automatic, per-process garbage collection
There is no
malloc/free pair to manage in Erlang — no leaks from a forgotten free, no double-frees, no use-after-free dangling pointers.#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *numbers = malloc(5 * sizeof(int));
for (int i = 0; i < 5; i++) {
numbers[i] = i * i;
}
printf("%d\n", numbers[4]);
free(numbers); // forgetting this line leaks memory
return 0;
}Numbers = [N * N || N <- lists:seq(0, 4)],
io:format("~p~n", [lists:nth(5, Numbers)]).
% No free() anywhere — Erlang's per-process garbage collector
% reclaims Numbers automatically once nothing references itEach Erlang process has its OWN independent heap and its own garbage collector — a genuinely different memory model from a single shared C heap, meaning one process's collection pause never stops any other process.
Per-process heaps: a genuinely new memory model
C has exactly one heap per operating-system process; Erlang gives every lightweight process its own independent, independently garbage-collected heap.
// C has one shared heap for the entire process — every malloc'd
// block lives in the same address space, reachable by every part
// of the program, with no per-task isolation at all:
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *shared = malloc(sizeof(int));
*shared = 42;
printf("%d\n", *shared);
free(shared);
return 0;
}Value = 42,
io:format("~p~n", [Value]).
% This value lives on THIS process's own private heap — no other
% Erlang process can reach it directly at all, and this process's
% GC runs independently of every other process's GCThat per-process heap is a structural isolation boundary C's memory model has no equivalent for at all.
Mutable Variables vs. Single Assignment
C variables are freely reassignable; Erlang variables bind once
Watch out: an Erlang variable binds exactly once — there is no reassignment at all, the single biggest habit change coming from C.
#include <stdio.h>
int main(void) {
int x = 40;
x = 41; // perfectly ordinary reassignment in C
printf("%d\n", x);
return 0;
}X = 40,
% X = 41 below would be a {badmatch,41} error — Erlang variables
% bind exactly once, period, enforced by the language itself:
io:format("~p~n", [X]).Attempting to rebind is a pattern-match failure (
{badmatch, NewValue}), enforced structurally by the language, not just discouraged by convention.No Pattern Matching vs. Pattern Matching
What C has no equivalent for: pattern matching
Pattern matching is a genuinely new capability for a C programmer, not different syntax for something C already has: Erlang's
case matches against a value's SHAPE directly.#include <stdio.h>
const char *classify(int n) {
if (n == 0) {
return "zero";
} else if (n > 0) {
return "positive";
}
return "negative";
}
int main(void) {
printf("%s\n", classify(-5));
return 0;
}Classify = fun(N) ->
case N of
0 -> zero;
N when N > 0 -> positive;
_ -> negative
end
end,
io:format("~p~n", [Classify(-5)]).C's
switch only matches literal integers/characters, with no destructuring at all — every "which shape is this value" decision must be an explicit chain of if/else over already-extracted fields. In Erlang, tuples, lists, and specific literals can all be destructured and matched in the same expression, with an optional when guard attached to any clause.Destructuring a tuple: impossible in one step in C
Erlang's
{X, Y} = Pair destructures a tuple into named variables in one matching expression.#include <stdio.h>
int main(void) {
// C has no tuple type at all — the closest equivalent is a
// struct, and destructuring it still means accessing fields
// one at a time, never in a single matching expression:
struct Pair { int x; int y; };
struct Pair pair = {3, 4};
printf("%d and %d\n", pair.x, pair.y);
return 0;
}Pair = {3, 4},
{X, Y} = Pair,
io:format("~p and ~p~n", [X, Y]).C has no tuple type at all; the closest analogue, a struct, still requires accessing each field individually (
pair.x, then pair.y) rather than pulling both out in a single destructuring step.Structs vs. Tagged Tuples
A struct versus a tagged tuple
Where C declares a named
struct layout at compile time, Erlang uses a tagged tuple — an ordinary tuple whose first element is a distinguishing atom, purely by convention.#include <stdio.h>
struct Point {
int x;
int y;
};
int main(void) {
struct Point point = {3, 4};
printf("%d and %d\n", point.x, point.y);
return 0;
}Point = {point, 3, 4},
{point, X, Y} = Point,
io:format("~p and ~p~n", [X, Y]).No compile-time declaration checks that every "point" tuple actually has this shape; the shape is enforced only by the pattern match at the point of use.
Modeling "one of several shapes" — no union type needed
C's closest tool for "one of several shapes" is a hand-assembled tagged union; Erlang tagged tuples give the same idea directly, with pattern matching doing the discrimination safely.
#include <stdio.h>
// C's closest tool for "one of several shapes" is a tagged union —
// a struct with an explicit tag field plus a union of the possible
// payloads, all wired up and checked by hand:
enum ShapeType { CIRCLE, RECTANGLE };
struct Shape {
enum ShapeType type;
union {
double radius;
struct { double width, height; } rectangle;
} data;
};
int main(void) {
struct Shape circle = { CIRCLE, { .radius = 5.0 } };
printf("%f\n", circle.data.radius);
return 0;
}Circle = {circle, 5.0},
Rectangle = {rectangle, 3.0, 4.0},
Area = fun
({circle, Radius}) -> 3.14159 * Radius * Radius;
({rectangle, Width, Height}) -> Width * Height
end,
io:format("~p~n", [Area(Circle)]).The C version needs an explicit tag field, a
union of the possible payloads, and manual discipline to always check the tag before reading the right union member — reading the wrong one is undefined behavior, with no compiler help.Null-Terminated Strings vs. Charlists
A null-terminated buffer versus a list of character codes
Erlang's double-quoted
"hello" is secretly a LIST of integer character codes, [104, 101, 108, 108, 111] — not a byte buffer.#include <stdio.h>
#include <string.h>
int main(void) {
char text[] = "hello";
printf("%zu\n", strlen(text));
return 0;
}Text = "hello",
io:format("~p~n", [length(Text)]).C strings are a contiguous buffer of bytes terminated by a
\0 sentinel — strlen must scan byte by byte until it finds that terminator. Erlang's length/1 is the same function used for any list, since a string genuinely is one.No fixed buffer size — no C-style buffer overflow possible
Erlang has no fixed-size string buffers, so C's classic buffer-overflow bug category simply cannot occur.
#include <stdio.h>
#include <string.h>
int main(void) {
// A fixed-size C buffer can overflow if the source string is too
// long — a classic, genuinely dangerous C bug category:
char buffer[6];
strcpy(buffer, "hello"); // fits exactly — but a longer source
// string here would overflow buffer
printf("%s\n", buffer);
return 0;
}Text = "hello, this can be as long as you like",
io:format("~p~n", [Text]).C requires a pre-sized destination buffer for any string copy, and writing more bytes than the buffer holds is undefined behavior. Erlang's list-of-codes representation grows to whatever length the actual data requires.
No Equivalent to Pointers
Pointers: a concept Erlang has no equivalent for
Erlang has no pointers, no addresses, and no way to ask where a value lives in memory — a genuine ABSENCE for a C developer to internalize, not different syntax for the same idea.
#include <stdio.h>
int main(void) {
int value = 42;
int *pointer = &value; // "&" takes the address of value
printf("%d\n", *pointer); // "*" dereferences — reads through the pointer
return 0;
}Value = 42,
% Erlang has no addresses, no pointers, and no way to ask
% "where does this value live in memory":
io:format("~p~n", [Value]).Erlang values are manipulated purely by binding, matching, and passing. C's
&value (take the address) and *pointer (follow the address) expose the machine's actual memory layout directly — the foundation of C's performance and of nearly every one of its classic bug categories, none of which Erlang can have at all.Undefined Behavior vs. Clean Crash
Undefined behavior versus a caught, well-defined runtime error
Integer division by zero is undefined behavior in C; in Erlang it is
badarith — a well-defined, catchable, documented runtime error.#include <stdio.h>
int main(void) {
int numerator = 10;
int denominator = 0;
// numerator / denominator here would be UNDEFINED BEHAVIOR in C —
// typically a crash (SIGFPE) on most platforms, but the C standard
// does not guarantee ANY particular outcome at all:
printf("skipped to avoid actually invoking undefined behavior\n");
return 0;
}try 10 div 0 of
Result -> io:format("~p~n", [Result])
catch
error:badarith -> io:format("crashed: badarith~n")
end.The C standard makes no guarantee about the outcome at all, and platforms genuinely differ (commonly a
SIGFPE crash, but never guaranteed). Erlang raises exactly this exception, every time, on every platform, and try ... catch handles it predictably.A whole-program crash versus an isolated, contained one
This is the deepest philosophical gap between the two languages: in C one bad memory access can end the whole program; in Erlang a process crash is a routine, local, contained event.
#include <stdio.h>
int main(void) {
// A segfault or an unhandled signal in C typically takes down the
// ENTIRE process — there is no per-task isolation boundary the way
// an Erlang process provides. (Not actually triggered here, since a
// real segfault would stop the test harness, not just this example.)
printf("in C, one bad memory access can end the whole program\n");
return 0;
}Pid = spawn(fun() -> 1/0 end),
% THIS process crashes — every other process, and the whole rest
% of the system, keeps running completely unaffected:
io:format("~p~n", [is_pid(Pid)]).A segfault, stack overflow, or sufficiently severe undefined behavior in C takes down every unrelated task in the same OS process — there is no isolation boundary. When an Erlang process crashes, every other process keeps running, and a supervisor typically restarts the failed one within milliseconds.
Threads vs. Isolated Processes
Shared-memory threads versus share-nothing processes
C threads share one address space and coordinate with locks; Erlang processes share NOTHING and communicate only by message passing (
! to send, receive to wait).// C has no concurrency primitives built into the language at all —
// threads are an OS-level library (pthreads) bolted on, and every
// thread shares the SAME memory space by default, requiring
// explicit locks/mutexes to coordinate safely:
#include <stdio.h>
int main(void) {
printf("C threads share memory by default — Erlang processes do not\n");
return 0;
}Pid = spawn(fun() ->
receive
{greeting, Message} -> io:format("~s~n", [Message])
end
end),
Pid ! {greeting, "hello"}.C has no concurrency primitives in the language itself — POSIX threads are an OS-level library bolted on, inviting classic data-race bugs whenever a lock is forgotten. Erlang's share-nothing design structurally rules out that entire category of shared-memory races.
Lightweight processes: orders of magnitude cheaper than OS threads
Erlang processes are orders of magnitude cheaper than OS threads — spawning ten thousand of them, as this example does, is unremarkable, everyday Erlang.
// A single OS thread in C typically costs megabytes of stack
// space and real operating-system scheduling overhead — spawning
// even a few thousand is a real resource commitment:
#include <stdio.h>
int main(void) {
printf("OS threads are comparatively heavyweight\n");
return 0;
}Pids = [spawn(fun() -> ok end) || _ <- lists:seq(1, 10000)],
io:format("~p~n", [length(Pids)]).An OS thread in C typically costs megabytes of reserved stack and real kernel scheduling overhead — spawning tens of thousands is often impractical. An Erlang process is managed entirely by the BEAM runtime and starts at roughly three hundred machine words (a couple of kilobytes).
#define vs. -define
Both macro systems are textual substitution, not syntactic
A genuine, direct parallel: C's
#define NAME Value and Erlang's -define(NAME, Value). are both preprocessor-level textual substitution.#include <stdio.h>
#define MAX_RETRIES 3
int main(void) {
printf("%d\n", MAX_RETRIES);
return 0;
}% Erlang macros are defined at the top of a module (-module
% required, so this is illustrated as a comment in this
% expression-level sandbox):
% -define(MAX_RETRIES, 3).
% Usage: ?MAX_RETRIES expands to 3 wherever it appears.
io:format("~p~n", [3]).The macro name is replaced with its expansion before the code is even compiled, with no awareness of scope, types, or syntax structure. Neither is the more powerful syntactic/hygienic macro system Clojure, Rust, or Elixir offer.
Arrays vs. Lists
A fixed-size array versus a linked list
A C array is a fixed-size contiguous block with O(1) indexing; Erlang's
[1, 2, 3, 4, 5] is a singly-linked list with the opposite performance profile.#include <stdio.h>
int main(void) {
int numbers[] = {1, 2, 3, 4, 5};
for (int i = 0; i < 5; i++) {
printf("%d ", numbers[i]);
}
printf("\n");
return 0;
}Numbers = [1, 2, 3, 4, 5],
io:format("~p~n", [Numbers]).Indexing the Nth element of an Erlang list is O(n), but prepending an element or matching the head off the front is O(1) — so idiomatic Erlang works from the head, never by index.
map and filter — no manual loop needed
Erlang's
lists:map/2 transforms a whole list in one call — C has no built-in higher-order collection functions at all.#include <stdio.h>
int main(void) {
int numbers[] = {1, 2, 3, 4, 5};
int doubled[5];
for (int i = 0; i < 5; i++) {
doubled[i] = numbers[i] * 2;
}
for (int i = 0; i < 5; i++) {
printf("%d ", doubled[i]);
}
printf("\n");
return 0;
}Numbers = [1, 2, 3, 4, 5],
Doubled = lists:map(fun(N) -> N * 2 end, Numbers),
io:format("~p~n", [Doubled]).The C version means an explicit indexed loop, a separately allocated destination array, and hand-managed bounds; the Erlang version has no manual loop, no separate destination allocation, and no index bookkeeping.
Recursion & Tail Calls
A compiler courtesy versus a formal language guarantee
Tail-call elimination is a formal language guarantee in Erlang; in C it is merely an optimization some compilers sometimes perform.
#include <stdio.h>
// GCC MAY optimize this into a loop with -O2, eliminating the
// recursive stack growth — but the C STANDARD makes no such
// guarantee at all; a sufficiently large N could still overflow
// the stack, and whether it does depends entirely on compiler
// flags and platform:
long sumTo(long n, long accumulator) {
if (n == 0) {
return accumulator;
}
return sumTo(n - 1, accumulator + n);
}
int main(void) {
printf("%ld\n", sumTo(5, 0));
return 0;
}SumTo = fun
SumTo(0, Accumulator) -> Accumulator;
SumTo(N, Accumulator) -> SumTo(N - 1, Accumulator + N)
end,
io:format("~p~n", [SumTo(5, 0)]).Whether GCC turns
sumTo's recursive call into a loop depends on the compiler, the optimization level, and even unrelated code changes — the C standard never requires it, so a sufficiently deep call could genuinely overflow the stack. Erlang guarantees proper tail calls in every implementation, at every optimization level.Gotchas for C Developers
Atoms: a data type C has no equivalent for
An Erlang atom like
ok is a self-evaluating, interned constant that springs into existence at the point of use — no declaration required, no enclosing type. C has nothing equivalent.// C has no equivalent to an Erlang atom — the closest analogue is
// an enum constant, which must be declared up front as part of a
// specific enum type, unlike an atom, which needs no declaration
// and belongs to no particular type at all:
#include <stdio.h>
enum Status { OK, FAILED };
int main(void) {
enum Status status = OK;
printf("%d\n", status);
return 0;
}Status = ok,
io:format("~p~n", [Status]).C's closest analogue, an
enum constant, must be declared upfront as part of one specific enum type, and prints as a bare integer by default (as shown — OK prints 0, not a name), unlike an atom, which always prints its own name.No null pointer to worry about — but also no null shortcut
Erlang has no
null concept whatsoever — the atom undefined conventionally means "no value," but it is an ordinary atom with no special language treatment.#include <stdio.h>
#include <stdlib.h>
int main(void) {
// NULL is a real, ever-present possibility for any C pointer —
// dereferencing one that turns out to be NULL is a classic,
// extremely common C crash:
int *pointer = NULL;
if (pointer == NULL) {
printf("pointer is NULL\n");
}
return 0;
}Value = undefined,
% "undefined" is just an ordinary atom with no special language
% treatment — Erlang has no actual null concept, and nothing
% forces you to check for "undefined" the way NULL forces a check:
io:format("~p~n", [Value]).C's
NULL is a genuine, structural hazard: any pointer might be NULL, and dereferencing one that is triggers a crash — one of the most common C bugs of all. In Erlang there is nothing to dereference, so the hazard disappears along with the shortcut.