Every language
question is a
question of when.
Compiled or interpreted. Static or dynamic. Value or reference. Recursion or iteration. Four arguments that sound like they are about what a language is — and every one of them is really about when something happens: when your code gets translated, when a type gets checked, what gets copied at the moment of a call, and when the stack runs out of room.
Four clocks
A program is text until something turns it into action. Between the characters you type and the electricity that runs them, there are a handful of distinct moments, and almost every "why does this language do that?" question resolves once you know which moment you are standing in.
None of these is a value judgement. Each is a bet about where the cost is cheapest to absorb, and each bet has a characteristic bug that shows up when it goes wrong. This handbook walks the four in order, with the code that makes the difference visible.
Translation time: compiled vs interpreted
When does your source text become instructions a CPU can run?
Your source code is just text — rows of characters in a file. A CPU can't run text; it only runs machine instructions, raw numbers loaded into registers and executed one after another. So before any program does anything, something has to translate what you wrote into instructions the processor understands. "Compiled versus interpreted" isn't really about two kinds of language — it is one question in disguise: when does that translation happen?
Compiled: translate the whole book up front
A compiled language translates the entire program before it ever runs. A compiler reads your source, works through it in full, and produces a standalone executable made of machine code — instructions specific to a CPU architecture, ready to load and run directly. It is the difference between hiring a translator to render an entire book once, cover to cover, versus translating it aloud as someone reads.
The payoff shows up at runtime: the translation work is already done, so the CPU executes native instructions with nothing left to interpret. The cost shows up earlier — you wait through a build, and the artefact is tied to the architecture and operating system it was built for.
$ gcc -O2 hello.c -o hello # translate once, ahead of time
$ file hello
hello: ELF 64-bit LSB pie executable, x86-64, dynamically linked
$ ./hello # the kernel loads it; the CPU runs it directly
The build step is where the whole program is read, checked, optimised, and lowered to x86-64 — and where an architecture gets baked in.
That last line of file output — dynamically linked — is the source of the most common compiled-language bug: a binary built against one C library refuses to start on a machine with an older one. The executable is machine code, but it is machine code for a machine. If that world (loaders, shared objects, ldd) is unfamiliar, the Linux internals handbook covers what actually happens between ./hello and your first instruction.
Interpreted: translate it live, line by line
An interpreted language skips the up-front step. Instead of producing a separate machine-code file, an interpreter reads your source and executes it directly, at the moment the program runs. There is no build step: you run the source immediately, and it runs on any machine that has the interpreter installed, regardless of the underlying architecture — that is most of what "portable" means in practice. The cost is that the runtime has to do translation work on every run, which makes pure interpretation slower, typically by one to two orders of magnitude for tight numeric loops.
The part the two-box picture misses
Nearly every "interpreted" language in production today doesn't interpret raw source text at all. It first compiles to bytecode — a compact instruction set for a virtual machine rather than a real CPU — and interprets that. You can see CPython's, and it is not mysterious:
>>> import dis
>>> def add(a, b): return a + b
>>> dis.dis(add) # CPython 3.11
1 0 RESUME 0
2 LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP 0 (+)
10 RETURN_VALUE
Python compiles — it just compiles to a virtual machine's instruction set, and caches the result in __pycache__/*.pyc so the next run skips the parse.
Some runtimes go one step further. A JIT (just-in-time) compiler watches bytecode execute, notices which paths run over and over, and compiles exactly those to real machine code on the fly. V8 runs JavaScript through a tiered pipeline — the Ignition interpreter, then the Sparkplug baseline compiler, then the TurboFan optimising compiler for genuinely hot code — and can deoptimise back down when an assumption it made (say, that a function always receives numbers) turns out to be wrong. The JVM's HotSpot does the same with C1 and C2 tiers. PyPy applies the idea to Python; CPython does not ship a production JIT, which is a large part of why it is slower than either.
| Runtime shape | When translation happens | Examples |
|---|---|---|
| Ahead-of-time compiled | Entirely before you run it, to native machine code | C, C++, Rust, Go |
| Bytecode + interpreter | To bytecode on load (cached), interpreted every run | CPython, CRuby (YARV) |
| Bytecode + JIT | To bytecode, then hot paths to machine code while running | JVM (HotSpot), V8, .NET CLR, PyPy |
| Transpiled | To another source language, which then runs however it runs | TypeScript → JavaScript |
JIT warm-up makes microbenchmarks lie. Time a JavaScript or Java function once and you are measuring the interpreter; run it ten thousand times and you are measuring optimised machine code — often 10× faster. Any benchmark without a warm-up phase is measuring the wrong tier, which is why every serious harness (JMH, benchmark.js) has one.
So the sharper question is never "is this language compiled or interpreted?" It is "when does this runtime translate my code — and what does it know at that moment?" A compiler that sees the whole program can inline and constant-fold; a JIT that sees the running program can specialise on the types it actually observed. They are optimising with different information, at different times.
Compiled vs Interpreted: When Your Code Gets Translated
Type-check time: static vs dynamic
When does the language tell you a value is the wrong shape?
Somewhere in your code, a function that expects a number is about to receive a string. The interesting question isn't whether that's a bug — it obviously is — it's when the computer tells you. Some languages read the entire program before it runs and refuse to build it if the types don't line up. Others don't care what a variable holds until the exact line that mishandles it executes, which might be on the first run, three weeks into production, or never.
Static: rejected before it starts
In a statically typed language, every variable, parameter, and return has a type known at compile time. The compiler walks the whole program and checks that every value flows somewhere that can legally hold it:
// Java — the build fails; this code never runs
int n = "hello";
// ^ error: incompatible types: String cannot be converted to int
Because the compiler commits to knowing every type ahead of time, that knowledge also powers the tooling built on top of it: precise autocomplete, refactors you can trust not to silently break a call site, and types that double as documentation which can't drift out of date. The cost is up-front ceremony — you declare types (or structure code so they can be inferred), and you pay that tax on every build, whether or not that line was ever going to be wrong.
Dynamic: it fails when that line runs
A dynamically typed language makes the opposite bet. A variable doesn't carry a fixed type — it points at whatever value it currently holds, and the check happens at runtime, at the moment an operation is attempted. The mismatch isn't a problem until the line that trips over it runs. What makes this genuinely dangerous is that the failure isn't always an error:
def area(w, h):
return w * h
area(3, 4) # 12
area(3, "4") # '444' <-- no error. int * str repeats the string.
area("3", "4") # TypeError: can't multiply sequence by non-int of type 'str'
The middle call is the one that reaches production: wrong answer, right type, no exception, and a downstream report full of '444'.
Static and strong are different axes
When types are checked (static vs dynamic) is independent of how much implicit conversion the language will do when they don't match (strong vs weak). Conflating the two is the single most common misconception in this area.
| Strongly typed (few implicit conversions) | Weakly typed (coerces freely) | |
|---|---|---|
| Static (checked before running) | Java, Rust, Go, Haskell | C — casts and implicit numeric conversions let mismatches through |
| Dynamic (checked as it runs) | Python, Ruby — "1" + 1 raises immediately | JavaScript, PHP — "1" + 1 quietly becomes "11" |
// JavaScript: dynamic AND weak — the coercion rules do the damage
"2" * "3" // 6 (both coerced to numbers)
"2" + "3" // "23" (+ prefers string concatenation)
1 + null // 1 (null coerces to 0)
[] + {} // "[object Object]"
Python is dynamic but strong: "2" + 2 raises a TypeError on the spot rather than inventing an answer.
Gradual typing, and the lie at the boundary
Gradual typing bolts an optional static checker onto a dynamic language: write loosely where it doesn't matter, add types where correctness is worth the ceremony. TypeScript is a compile-time layer over JavaScript; Python's type hints (PEP 484) are annotations that a tool like mypy checks statically. In both cases the checking is opt-in, incremental, and — critically — gone at runtime.
Type erasure
TypeScript types and Python annotations are erased or ignored during execution. The emitted JavaScript contains no type information at all, and CPython never validates a hint. A type annotation is a claim the checker verifies about your code — it is not a guard on data arriving from outside it.
interface User { id: number; name: string }
const res = await fetch("/api/user");
const u: User = await res.json(); // .json() returns `any` — nothing is checked
console.log(u.id.toFixed(2)); // TypeError at runtime if the API sent "42"
def area(w: int, h: int) -> int:
return w * h
area("3", 4) # '3333' — CPython ignores annotations completely
The same bug in both languages: types are checked where you wrote them, not where the data came from.
A confident type on a JSON.parse, a fetch response, an environment variable, or a database row. Those values cross a boundary the checker never saw, so the annotation is a promise you made, not a check anything performs. Validate at the edge — a schema library at the boundary, plain types everywhere inside it.
Which bet is worth making depends on how long the code lives and how many people touch it. Large, long-lived systems with many contributors lean static, where ceremony pays for itself as the codebase grows and refactors get scary. A throwaway script leans dynamic, where writing speed beats proving correctness ahead of time. Neither removes the mismatch — they disagree about when you find out.
Static vs Dynamic Typing: When Type Errors Get Caught
Call time: what actually gets copied
You pass a variable into a function, the function changes it, the function returns. Does the caller see it?
The honest answer is: it depends — not on the language being good or bad about it, but on one mechanical question. Did the function get a copy of your data, or did it get the real thing? Once you know which, the behaviour stops being surprising. The trouble is that most popular languages sit in an in-between spot that neither word describes, and that spot is where the confusion lives.
The two clean cases
Pass by value means the function receives a duplicate — a photocopy of a page rather than the original. Mark it up all you like; the caller's copy is untouched, because the two were never the same memory. Pass by reference is the other extreme: the function gets the variable itself, the same storage location under a different local name. Because there is only one location, the function can do the thing a copy never can — reassign it.
/* C — always pass by value. Passing a pointer copies the pointer. */
void bump(int x) { x += 1; } /* the caller's int is untouched */
void bump_ptr(int *x) { *x += 1; } /* still a copied pointer — but it aims at n */
int n = 1;
bump(n); /* n == 1 */
bump_ptr(&n); /* n == 2 */
// C++ — a genuine reference parameter
void bump(int &x) { x += 1; } // one storage location; the caller's n really moves
// C# — the `ref` keyword is real pass by reference
void Bump(ref int x) { x = 99; }
The gotcha: languages that pass the reference by value
Java, Python, JavaScript, and Ruby are not pass by value in the "everything is copied" sense, and they are not pass by reference either. When you pass an object, the function receives a copy of the reference — a copy of the address, not a copy of the object, and not the caller's address slot. Liskov's name for this, from CLU, is call by sharing, and it explains both halves of the gotcha at once. Because the copied address still points at the same object, the function can follow it and mutate that object, and the caller sees it. Because the address itself is a copy, the function can't move the caller's variable — it can only overwrite its own local copy.
def mutate(lst):
lst.append(9) # follows the shared address → caller sees it
def rebind(lst):
lst = [9] # overwrites the LOCAL copy of the address → caller sees nothing
a = [1]; mutate(a); print(a) # [1, 9]
b = [1]; rebind(b); print(b) # [1]
Same parameter, same assignment-looking line, opposite outcomes. One mutates; one reassigns.
This is also why "Java is pass by value" is a true statement even though objects clearly get modified inside methods. The reference is passed by value, so reassigning the parameter never reaches the caller — but calling a mutating method on it does, because both sides hold an address to the one shared object.
| Language | What the callee receives | Mutation visible to caller? | Reassignment visible? |
|---|---|---|---|
| C | A copy of the value (a copy of the pointer, if you pass one) | Only through a pointer | No |
| C++ T& / C# ref | The caller's variable itself | Yes | Yes |
| Java, Python, JS, Ruby | A copy of the reference (call by sharing) | Yes | No |
| Go | A copy of the value — structs are copied whole | Via pointer, slice element, or map | No |
The three bugs this actually causes
First, Python's mutable default argument. Default values are evaluated once, when the def executes — not on each call — so every call without that argument shares one object:
def add_item(item, basket=[]): # evaluated ONCE at definition time
basket.append(item)
return basket
add_item("apple") # ['apple']
add_item("pear") # ['apple', 'pear'] <-- same list, forever
def add_item(item, basket=None): # the fix
if basket is None:
basket = []
basket.append(item)
return basket
Second, aliasing — two names for one object, including inside a "copy":
a = [1, 2, 3]
b = a # not a copy: the same list under a second name
b.append(4)
print(a) # [1, 2, 3, 4]
c = a[:] # shallow copy — new outer list, SAME inner objects
grid = [[0] * 3] * 3 # three references to ONE row
grid[0][0] = 1
print(grid) # [[1,0,0],[1,0,0],[1,0,0]]
Use [[0] * 3 for _ in range(3)], or copy.deepcopy when the nesting is arbitrary.
Third, Go's slice header. A slice is a small struct — pointer, length, capacity — and it is copied by value like any struct. Writing through it reaches the shared backing array, but append updates the local length (and may allocate a whole new array), so the caller never sees the growth:
func addOne(s []int) { s = append(s, 1) } // caller does NOT see the new element
func setZero(s []int) { s[0] = 0 } // caller DOES see this write
The pattern generalises. Stop asking "does this language pass by value or by reference" — in Java, Python, JavaScript, Ruby, and Go you always get a copy of something. Ask instead: does this line mutate the shared object, or reassign a local variable? Mutation travels back to the caller. Reassignment stays local. Once two threads hold references to the same mutable object, that same aliasing turns into a data race — which is where concurrency and locking picks the story up.
Pass by Value vs Pass by Reference: What Actually Gets Copied
Stack-frame time: recursion vs iteration
Two shapes for the same job — and why one of them runs out of room.
Every repeating problem can be solved two ways: have a function call itself, or run a loop. They compute the same answer out of completely different shapes, and that difference in shape is why one can crash on an input the other handles without blinking.
Recursion is a function that calls a smaller version of itself. To answer for n, it asks the same question for a smaller input, which asks again, and so on — nesting doll after nesting doll — until it reaches an input small enough to answer directly. That is the base case. Then each waiting call combines its own work with the answer it got back, closing the dolls in reverse. Iteration does the same job with a loop: one worker climbing stairs, carrying a running total, ticking a counter, never pausing mid-calculation to wait on anyone.
What makes recursion worth reaching for isn't brevity — it is that the code mirrors the structure of the problem. A tree is recursive (a node plus children, which are nodes plus children), so a recursive traversal reads as "handle this node, then recurse on each child," matching the data instead of fighting it. That is why it fits trees, graphs, and divide-and-conquer. Iteration fits flat, linear work: summing a list, scanning an array, draining a stream.
The catch: the call stack is finite
Every recursive call that opens before the previous one finishes has to be remembered somewhere, so it can resume once the inner call returns. That somewhere is the call stack: one stack frame per open call, holding its arguments, its locals, and the return address to jump back to. A loop never does this — it updates its state in place, in the same frame, forever. Recursion adds a frame per level of depth, and the stack has a hard ceiling.
Real limits, real numbers
CPython raises RecursionError at a default sys.getrecursionlimit() of 1000 frames. The JVM throws StackOverflowError after roughly 10,000–20,000 frames on its default 512 KB–1 MB thread stack. V8 raises RangeError: Maximum call stack size exceeded around 10,000 frames. Underneath all of them, the operating system thread stack itself is typically 8 MB for the main thread on Linux (ulimit -s reports 8192 KB).
Note the two ceilings. Python's 1000 is a soft guard the interpreter enforces; the 8 MB OS stack is the real wall. Raising sys.setrecursionlimit(1_000_000) removes the guard without moving the wall, so a deep enough recursion stops raising a catchable RecursionError and starts segfaulting instead. If frames, stacks, and thread limits are new territory, the OS fundamentals handbook is the place to see where that 8 MB actually lives.
Tail calls: the optimisation most languages don't have
A call is in tail position when it is the very last thing a function does — nothing is left to combine afterwards. In principle the current frame can be reused instead of stacked, turning the recursion into a loop:
def fact(n, acc=1):
if n == 0:
return acc
return fact(n - 1, acc * n) # tail position: nothing pending after the call
fact(100_000) # RecursionError — CPython does NOT eliminate tail calls
Scheme and Lua guarantee proper tail calls in their specifications. ES6 specified them for JavaScript, but only JavaScriptCore (Safari) shipped it. CPython deliberately does not, in part to preserve readable tracebacks.
So in most languages, writing recursion in tail form buys you nothing. The portable fix is to convert the recursion into a loop with an explicit stack on the heap, which has no 8 MB ceiling — the same algorithm, the frames just moved:
def walk(root): # iterative DFS — heap stack, constant frames
stack = [root]
while stack:
node = stack.pop()
visit(node)
stack.extend(reversed(node.children))
| Recursion | Iteration | |
|---|---|---|
| Shape | Calls a smaller version of itself down to a base case, then combines back up | A loop with a running total, one flat pass |
| Memory | O(depth) — one stack frame per open call | O(1) — fixed state, reused frame |
| Per-step cost | Call overhead: push frame, save registers, jump, return | A compare and a jump |
| Failure mode | Stack overflow on deep input | Infinite loop or an off-by-one |
| Best for | Self-similar structure: trees, graphs, divide-and-conquer | Flat linear work, or depth that scales with input size |
A recursive walk that was fine on test data and dies on real data. Recursively parsing deeply nested JSON blows CPython's limit long before the input looks large. Recursively reversing a linked list is elegant at length 10 and a crash at length 100,000 — because a linked list is the degenerate case of a tree, depth equal to length. The fix is never "raise the limit"; it is to move the stack to the heap.
The two shapes aren't different in raw power: any recursion can be rewritten as a loop plus an explicit stack you manage yourself. The productive path is often to write the recursion first because it is obviously correct, memoise it so repeated subproblems are computed once, then flatten it into a bottom-up loop — which is exactly the arc from naive recursion to dynamic programming in coin change and the knapsack problem. And when the structure really is a tree, recursion is simply the right answer, as in tree DP. Ask which the problem looks like: does it nest inside itself like dolls, or line up in a row like stairs?
Recursion vs Iteration: Why One Blows the Stack
Reading the clocks
Put the four side by side and the same shape appears each time: a cost that has to be paid somewhere, and a language deciding whether to charge you early or late.
| Clock | Pay early | Pay late | What it costs you when it's wrong |
|---|---|---|---|
| Translation | Compile the whole program to machine code | Interpret bytecode; JIT the hot paths | A build step and an architecture lock-in, versus slower runs and warm-up cliffs |
| Type check | Compiler rejects mismatches before running | Runtime raises when that line executes | Ceremony on every line, versus a wrong answer in production |
| Call | Copy the data (value semantics) | Share an address; copy only the address | Copying cost, versus spooky action at a distance through aliases |
| Stack | Iterate — flat, constant memory | Recurse — a frame per open call | Clumsier code, versus a crash whose trigger is input depth |
Three of the four are, at bottom, the same lesson: the language is deciding what to know now and what to discover later. A compiler that reads everything up front can optimise aggressively but can't see real data. A JIT that waits can specialise on real data but must warm up first. A static checker proves things about code it can see and knows nothing about the JSON arriving over the wire. And a call stack is just the runtime remembering what it still owes you — which is only free until it isn't.
Learn to spot which clock a piece of surprising behaviour belongs to, and the surprise usually resolves into a rule you already knew.
Quick answers
Is Python compiled or interpreted?
Both, in stages. CPython compiles your source to bytecode ahead of execution and caches it in __pycache__ as a .pyc, then a virtual machine interprets that bytecode. There is no ahead-of-time machine-code step and no production JIT in CPython, which is why it is slower than Java or JavaScript; PyPy runs the same language with a tracing JIT. Compiled versus interpreted is a spectrum defined by when translation happens, not a label a language carries.
Is Java pass by value or pass by reference?
Strictly pass by value. The confusion is that for an object the copied value is the reference. The copy still points at the same heap object, so mutating it (a setter, list.add) changes what the caller sees — but assigning a new object to the parameter only overwrites the method's local address, so the caller's variable never moves. Ask "mutate or reassign", not "value or reference".
Does TypeScript check types at runtime?
No. Types are erased at compile time and the emitted JavaScript carries none of them. This matters most at boundaries: JSON.parse and fetch responses are any, so annotating them is a promise you made rather than a check the runtime performs. Validate external data with a runtime schema at the edge, then rely on plain types inside.
Why does my recursive function crash when the loop works?
Each unfinished call keeps a stack frame alive, so memory grows with depth while a loop reuses one frame forever. CPython raises RecursionError at 1000 frames, the JVM throws StackOverflowError after roughly 10k–20k, and V8 raises a RangeError near 10k. When depth scales with input size, convert to a loop with an explicit heap stack — raising the limit only removes the guard, not the 8 MB OS stack behind it.
What is the difference between static typing and strong typing?
Independent axes. Static versus dynamic is when types are checked; strong versus weak is how much implicit coercion happens when they don't match. Python is dynamic but strong ("1" + 1 raises). JavaScript is dynamic and weak (the same expression yields "11"). C is static but relatively weak, because casts and implicit numeric conversions let mismatches through.
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Handbooks
- The Prompting HandbookA friendly, hands-on field guide for everyday humans — learn the CRISP framework, spot bad prompts, practice with real recipes, play a drag-and-drop game, and test yourself with a quiz. No code required.Read →
- The Agentic AI Interview HandbookTwenty topics every senior AI engineer should be able to reason about live — from eval pipelines to reliability patterns for generative systems.Read →
- The Senior AI Engineer Interview Handbook60 questions across architecture, production incidents, agentic systems, RAG, evals, cost, safety, and leadership — what staff-level AI interviewers actually probe for.Read →
- 50 Angular Interview QuestionsA visual handbook covering components, change detection, RxJS, signals, routing, forms, performance, and testing — what interviewers actually probe for in senior Angular roles.Read →
- 50 Python Interview QuestionsFundamentals to advanced: data structures, OOP, iterators & generators, the GIL, asyncio, memory, testing, and the standard library — a visual walk through everything a Python interview touches.Read →
- 51 LLM Evals Interview QuestionsGolden sets, LLM-as-judge, regression testing, offline vs online evals, RAG evals, agent evals, red-teaming, and observability — demystified for interviews and production.Read →
Explore more from Vibe Engines
Get the next one in your inbox.
New handbooks, system-design walkthroughs, and tools — straight to your inbox. No spam, unsubscribe anytime.