Output & Basics
Hello, World
#include <stdio.h>
int main(void) {
printf("Hello, World!\n");
return 0;
} io:format("Hello, World!~n"). C requires an explicit
#include, a typed int main(void) entry point, and an explicit return 0 on success. Erlang has no entry-point requirement at expression level at all — io:format/2 with the ~n directive prints with a newline, and this expression runs directly with no wrapping function.Sequencing multiple outputs
#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"). C separates statements with semicolons; Erlang shell expressions are separated by commas, with a single terminating period marking the end of the whole sequence — both simply execute top to bottom in the order written.
Static Types vs. Fully Dynamic
No compile-time type checking at all
#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)]). C checks
add's parameter and return types at compile time — passing a pointer where an int is expected is a compile error. Erlang has no compile-time type checking at all — Add("two", 3) would compile fine (there is no separate compile step at expression level to catch it) 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
// 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)]). Erlang's
-spec type annotations plus the separate Dialyzer tool can catch a real class of type mismatches, but only for code that opts in and only when Dialyzer 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
#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 it There is no
malloc/free pair to manage in Erlang, and no possibility of a memory leak from a forgotten free, a double-free, or a use-after-free dangling pointer. Each 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 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 GC C has exactly one heap per operating-system process, shared by every part of the program that runs inside it. Erlang gives every lightweight process its own independent heap, garbage collected independently — 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
#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]). A C variable can be reassigned as many times as the program likes — that is the entire point of a "variable." An Erlang variable, once bound, can never be rebound to a different value in the same scope; attempting to do so 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
#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)]). This is a genuinely new capability, not just different syntax for something C already has. 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. Erlang's case matches against a VALUE'S SHAPE directly — 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
#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]). Erlang's
{X, Y} = Pair destructures a tuple into named variables in one matching expression. 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
#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]). A C
struct is a fixed, named, typed layout declared once at compile time. An Erlang tagged tuple like {point, 3, 4} is just an ordinary tuple whose first element happens to be a distinguishing atom by convention — there is no compile-time declaration checking that every "point" tuple actually has this shape; it is enforced only by the pattern match at the point of use.Modeling "one of several shapes" — no union type needed
#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)]). C's closest tool for "one of several shapes" is a hand-assembled tagged union: 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). Erlang tagged tuples give the same idea directly, with the tag and payload already bundled together and pattern matching doing the discrimination safely.Null-Terminated Strings vs. Charlists
A null-terminated buffer versus a list of character codes
#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 double-quoted "hello" is secretly a LIST of integer character codes, [104, 101, 108, 108, 111] — 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
#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 — the classic buffer-overflow bug. Erlang's list-of-codes representation grows to whatever length the actual data requires; there is no fixed-size buffer to overflow at all.
No Equivalent to Pointers
Pointers: a concept Erlang has no equivalent for
#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]). This is a genuinely new ABSENCE for a C developer to internalize, not a different syntax for something Erlang already has. Erlang values are manipulated purely by binding, matching, and passing — never by "where they live." C's
&value (take the address) and *pointer (follow the address) expose the machine's actual memory layout directly, the foundation for C's performance and 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
#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. Integer division by zero in C is undefined behavior — the C standard makes no guarantee about the outcome at all, and platforms genuinely differ (commonly a
SIGFPE crash, but never guaranteed). Erlang's badarith is a well-defined, catchable, documented runtime error: dividing by zero produces exactly this exception, every time, on every platform, and try ... catch can handle it predictably.A whole-program crash versus an isolated, contained one
#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)]). This is the deepest philosophical gap between the two languages. A segfault, stack overflow, or sufficiently severe undefined behavior in C can bring down the ENTIRE process, taking every unrelated task running inside it down at the same time — C has no equivalent isolation boundary at all. An Erlang process crashing is a routine, local event: 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 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, and every thread shares the same address space by default, requiring explicit mutexes/locks to coordinate safely (and inviting classic data-race bugs when a lock is forgotten). Erlang processes are lightweight, BEAM-managed, and share NOTHING by default — the only way to communicate is explicit message passing (
! and receive), structurally ruling out an entire category of shared-memory data races.Lightweight processes: orders of magnitude cheaper than OS threads
// 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 of them is a genuine resource commitment, often impractical. Erlang processes are managed entirely by the BEAM runtime, starting at only a few hundred bytes each; spawning ten thousand of them, as shown, is unremarkable, everyday Erlang.
#define vs. -define
Both macro systems are textual substitution, not syntactic
#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]). This is a genuine, direct parallel: C's
#define NAME Value and Erlang's -define(NAME, Value). are both preprocessor-level textual substitution — 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
#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]). A C array is a fixed-size, contiguous memory block with O(1) indexed access — its size is baked in at declaration and cannot grow. Erlang's
[1, 2, 3, 4, 5] is a singly-linked list — indexing the Nth element is O(n), but prepending an element or matching the head off the front is O(1), the opposite performance profile from a C array.map and filter — no manual loop needed
#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]). C has no built-in higher-order collection functions at all — transforming every element of an array means writing an explicit indexed loop by hand, allocating the destination array separately, and managing both bounds yourself. Erlang's
lists:map/2 does the same job in one call, with no manual loop, no separate destination allocation, and no index bookkeeping.Recursion & Tail Calls
A compiler courtesy versus a formal language guarantee
#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)]). This is a real, formal difference, not just a style preference. Whether GCC turns
sumTo's recursive call into a loop is an OPTIMIZATION the C standard never requires — it depends on the compiler, the optimization level, and even unrelated code changes; a sufficiently large call could genuinely overflow the stack. The Erlang/BEAM specification formally GUARANTEES proper tail-call optimization for a call in tail position, in every implementation, at every optimization level — a language guarantee, not a compiler courtesy.Gotchas for C Developers
Atoms: a data type C has no equivalent for
// 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]). An Erlang atom like
ok is a self-evaluating, interned constant that springs into existence at the point of use — no declaration required anywhere, and comparable across the whole program regardless of context. 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
#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. Erlang has no null concept whatsoever; the convention of using the atom undefined to mean "no value" is just that, a convention, with no special compiler enforcement the way NULL at least gets a dedicated comparison and crash-on-dereference behavior in C.