Semanticsspec

Static and Dynamic Scoping

Under lexical scope a name means what the enclosing text says; under dynamic scope it means whatever the most recent caller bound it to. Nearly every modern general-purpose language chose lexical — and then reintroduced dynamic scoping deliberately, as `this`, thread-locals and context variables.

The question

What is dynamic scoping, and why does my lexically-scoped language still have some?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

Two different rules for the same lookup. Lexical: the visible bindings are the chain of enclosing scopes, fixed by the text and knowable at compile time. Dynamic: the visible bindings are the chain of active *calls*, which exists only at runtime and differs per call path. The representation question is whether the environment is a compile-time tree or a runtime stack, and that single choice determines what a compiler can decide in advance.

What this phase may assume or do

A compiler may resolve a name at compile time only if the language guarantees that the binding is determined by the enclosing text. For any dynamically-scoped construct that guarantee does not hold, and the compiler must emit a runtime lookup instead — the binding depends on the call path, and the call path is not knowable statically. Getting this backwards in either direction is a miscompilation: resolving a dynamic name lexically binds the wrong entity, and treating a lexical name as dynamic gives up every optimization that depends on knowing a local is unaliased.

Key points

  • Lexical scope resolves by enclosing text and is decidable at compile time; dynamic scope resolves by the call stack and is not.
  • Under dynamic scope the same function returns different results depending on who called it, which defeats reading, tooling and closures.
  • Nearly every modern general-purpose language chose lexical, and Scheme's 1975 switch is the usual marker for when the argument ended.
  • Genuinely dynamic languages persist: Emacs Lisp defvar, shell variables, Perl local, Common Lisp special variables.
  • JavaScript this, thread-locals, context variables, exception handler search and environment variables are all dynamically-scoped constructs inside lexically-scoped languages.
  • The classic difficulties of dynamic scope show up precisely at those constructs — which is what makes them hard, not any accident of their APIs.

The same program, two answers

The distinction has one canonical example and it is worth writing out. A function refers to a name it does not declare. Under lexical scope that name resolves to whatever declaration encloses the function *in the source text*. Under dynamic scope it resolves to whatever binding is active in the caller when the call happens.

The lexical answer is fixed at compile time and identical at every call site. The dynamic answer depends on who called, which means the same function returns different results depending on where it is invoked from — and it is not knowable until it happens.

One program, two scoping rules, two answers
1x = "global"
2
3fn show() { print(x) }
4
5fn caller() {
6 x = "local to caller" // a binding of x, active during this call
7 show()
8}
9
10caller()
11
12// Lexical scope: prints "global"
13// show's x resolves to the global declaration that encloses show's text.
14// Nothing caller does can change it.
15//
16// Dynamic scope: prints "local to caller"
17// show's x resolves to the nearest binding on the call stack, which is
18// caller's. Called from somewhere else, show would print something else.

The pseudo-code is deliberately ambiguous about what x = ... inside caller means, because that is precisely what the two rules disagree about: a new lexical binding invisible to show, or a dynamic rebinding that show will see.

Where dynamic scope actually lives

typicalCalling these constructs "dynamically scoped" is an accurate description of their lookup rule, and it is not usually the language's own terminology — no JavaScript specification calls this dynamic scoping. The label is useful because it predicts the behavior: like any dynamic binding, each of these is invisible in the function's own text, cannot be resolved by a static tool, and changes with the call path. Where the description breaks down is that these constructs are *narrow* — one distinguished value, or an explicit context object — so they do not carry the full cost of a dynamically-scoped language.

Almost no general-purpose language uses dynamic scope as its default rule any more, and the reasons are the ones you would expect: you cannot tell what a function does by reading it, no tool can resolve a name reliably, closures are not implementable, and a caller three frames up can change a callee's behavior by accident. Scheme's move to lexical scope in 1975 is generally taken as the point the argument ended.

But dynamic scope did not disappear — it got confined. Emacs Lisp keeps genuinely dynamic variables via defvar (and had them as the default for decades). Shell variables are dynamically scoped: an exported variable is visible to everything a script calls. Perl's local saves a global's value and restores it when the enclosing block exits, which is dynamic binding in a language that also has lexical my. Common Lisp has both, marked by declaration.

And — the part that matters for engineers who will never write Emacs Lisp — every mainstream lexically-scoped language has dynamically-scoped things inside it. They are not called scoping, which is why the connection is usually missed.

Dynamically-scoped constructs inside lexically-scoped languagestypical
ConstructWhat determines its valueWhy it is dynamic
JavaScript this (non-arrow)How the function was called, not where it was writtenThe same function body sees a different this per call site — which is why .bind() exists and why arrow functions, which capture this lexically, were added
Thread-localsWhich thread is executingA read finds the value bound by this thread's call history, not by the enclosing text
Context variables / async-local storageThe active context in the current taskExplicitly dynamic scoping for request ids, trace spans and locale, made async-safe
Exception handlersThe nearest enclosing try on the call stackA throw searches the *dynamic* chain — the handler that catches it depends on who called
Dependency injection contexts / React contextThe nearest provider above the consumer in the runtime treeA component reads what its ancestors provided at runtime, not what its file encloses
Environment variablesThe process's environment, inherited from its parentThe classic dynamic scope: bound by the caller and visible to everything below

What each rule buys and forbids

Lexical scope buys static decidability, and everything downstream of it: closures with a computable capture set, rename refactoring that is exact, go-to-definition with one answer, and the assumption that a local no one else can name is a local no one else can modify. That last one is the precondition for keeping values in registers, and it is worth a lot.

Dynamic scope buys implicit context. Every function below a binding sees it without anyone threading it through a parameter list — which is genuinely what you want for a logging level, a request id, a locale, a database transaction, or a test-time override. Threading those explicitly is the honest alternative and it is invasive: every intermediate function grows a parameter it does not use.

That is why the confined form won. Keep lexical scope as the rule, so that ordinary names stay statically decidable, and provide a narrow, explicitly-marked dynamic mechanism for the cases that genuinely need call-path-dependent binding. The cost is that these mechanisms are famously hard to reason about — this in JavaScript, thread-locals leaking across a thread pool, context variables lost across an await — and those difficulties are exactly the difficulties of dynamic scope, arriving in a small dose.

  • Lexical: readable, tool-friendly, closure-compatible, optimizable — and cannot express implicit context.
  • Dynamic: implicit context for free — and unreadable, untoolable, and incompatible with closures.
  • Confined dynamic (this, thread-locals, context vars) gets the useful part in a bounded, marked form.
  • The classic bugs of dynamic scope reappear at exactly those points: a thread-local read on a pooled thread that a previous request bound, a context variable that did not propagate across a task boundary.
  • A compiler cannot resolve any of them statically, which is why they are invisible to rename, find-references and dead-code analysis.

How it works

The steps, in the order the compiler takes them.

  • Lexical: the compiler builds a scope tree from the source, resolves each identifier by walking outward, and records the declaration. No runtime lookup remains for a local.
  • Dynamic: the runtime keeps a stack (or a per-thread map) of active bindings; a binding pushes on entry and pops on exit, and a read searches from the top.
  • A language with both marks which is which — Common Lisp's special declarations, Perl's my versus local — so the compiler knows whether to resolve or to emit a lookup.
  • Confined forms replace the general stack with one distinguished slot: this is passed as a hidden argument, thread-locals live in a per-thread table, context variables in a per-task immutable map.
  • Because a dynamic read cannot be resolved statically, it compiles to a lookup at every access — which is why these constructs cost more than a local and cannot be optimized like one.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A thread-local is set during a request, the thread returns to a pool, and the next request on that thread reads the previous request's value. The wrong user id appears in a log — or in an authorization decision.
  • A context variable is set before an await and read after it in a runtime that does not propagate context across task boundaries. The value is silently absent, and the trace has a hole exactly where the async call was.
  • A JavaScript method is passed as a callback and loses this. The error is "cannot read property of undefined", pointing inside a function that is correct, and only when it is called indirectly.
  • A shell script relies on an exported variable set by its caller. Run directly it works; run from cron, with a different environment, it silently takes a different branch.
  • A test sets a dynamically-scoped override and an assertion fails before the restore runs, so the override leaks into every subsequent test. The failures are order-dependent and unreproducible in isolation.
  • A refactoring tool renames a variable and misses a dynamic reference to it, because no static analysis can see that reference. The rename compiles and the runtime lookup now fails.

When it helps

  • Cross-cutting context that genuinely belongs to a call path: request id, trace span, locale, current user, active transaction, logging level.
  • Test-time overrides, where you want to substitute a dependency for the extent of one call without changing any signature.
  • Configuration that should apply to everything below a point without every intermediate layer knowing about it.

When it hurts

  • Ordinary program values. Anything that could be a parameter should be a parameter; dynamic binding makes data flow invisible and untestable.
  • Concurrency, where "the current binding" has to be defined per thread or per task, and every boundary — thread pool handoff, await, callback scheduling — is a place propagation can be lost.
  • Any codebase that needs reliable refactoring, since dynamic references are invisible to every static tool by construction.

What it costs

Every one of these is paid by something.

  • Lexical scope buys static resolution, closures and optimization, and costs the ability to pass context implicitly — which then has to be threaded explicitly through signatures that do not care about it.
  • Dynamic scope buys implicit context and costs readability, tooling, closures and any assumption that a local is private — a caller can change what a callee sees without touching it.
  • Confined dynamic constructs buy the useful subset and cost a per-access runtime lookup plus a propagation problem at every concurrency boundary, which is where their real bugs live.
  • Marking dynamic bindings explicitly (Common Lisp, Perl) buys a compiler that knows which rule to apply and costs the programmer a declaration they must not forget — forgetting it silently changes the semantics.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Explicit parameter passing: thread the context through every call. Fully visible, fully testable, fully static — and invasive enough that large codebases resist it, which is why implicit mechanisms keep being invented.
  • An explicit context object passed as one argument. The compromise most modern APIs settle on — Go's context.Context is exactly this, and it is dynamic scoping made explicit and lexically visible in every signature.
  • Reader monads and effect systems, which encode ambient context in the type. Static, checked, composable, and requiring a language and a team willing to pay for it — see [[effect-systems]].
  • Algebraic effects, which generalise both dynamic binding and exception handling into one mechanism with a handler installed by the caller. The most principled version available; still rare in production languages.

See it for yourself

The flag, dump or tool that shows you this directly.

  • Emacs Lisp: evaluate a defvar variable inside a let in a caller and read it in a callee — genuine dynamic scoping in a live REPL, in about four lines.
  • Bash: x=1; f() { echo $x; }; g() { local x=2; f; }; g prints 2. That is dynamic scope, in the tool you already have open.
  • Python: contextvars.ContextVar and threading.local() — set one, call through several frames, read it. Then do the same across an asyncio.create_task boundary and observe which one propagates.
  • JavaScript: call a method as obj.m() and then as const m = obj.m; m(), and compare this. Repeat with an arrow function to see lexical capture of this.
  • Java: ThreadLocal in a thread pool — set it, return the thread, and read it on the next task without clearing. The result is the leak described above, reproduced deliberately.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "Dynamic scoping means dynamic typing." Unrelated axes. Emacs Lisp is dynamically scoped and dynamically typed; Python is dynamically typed and lexically scoped; Common Lisp offers both scoping rules in one statically-compilable language.
  • "My language is lexically scoped, so none of this applies." this, thread-locals, context variables and exception handler search are all in your language and all resolve by call path.
  • "Dynamic scoping is obsolete." It is confined, not gone, and every framework that offers ambient context — React context, dependency injection scopes, async-local storage — has reinvented it deliberately.
  • "A context variable is just a global." A global has one value for the process. A context variable has a different value per call path, which is exactly what makes it dynamic scoping and exactly why it propagates across some boundaries and not others.

Misconceptions

The claim, and what is actually true.

Dynamic scoping is a historical mistake with no modern relevance.
Every ambient-context mechanism in modern software — thread-locals, context vars, DI scopes, React context — is dynamic scoping, and their characteristic bugs are dynamic scoping's characteristic bugs.
this in JavaScript is a scoping quirk.
It is a dynamically-bound name in a lexically-scoped language, which is why it changes with the call site and why arrow functions — which capture it lexically — fixed the problem by changing the rule.
You can find all uses of a dynamically-scoped variable with a search.
You can find the textual reads. You cannot determine statically which binding any of them will see, which is why rename and find-references cannot be exact for them.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

Two possible rules for what a name means when a function uses one it did not declare. Lexical: whatever encloses the function in the source. Dynamic: whatever the caller most recently bound. Lexical won everywhere because you can read it, tool it, and build closures on it. Dynamic survives in narrow places where implicit context is genuinely what you want.

practical

When a value is mysteriously wrong and the function reading it looks correct, ask whether that value is dynamically bound. Thread-locals on a pooled thread, context variables across an async boundary, and this in a detached callback are the three that account for most of these. The diagnostic move is the same in all three: log the binding at the point it is set and at the point it is read, with the thread or task id, and the gap becomes obvious immediately.

advanced

The deep reason lexical scope won is that it makes free variables computable, and free variables are what a closure must capture. Under dynamic scope a function's free variables have no determinate binding until it is called, so there is nothing to capture, and higher-order functions become unpredictable — the funarg problem that pushed Scheme to lexical scope and eventually everyone else with it. Modern algebraic-effect systems are the interesting reopening of the question: they give the caller the ability to install a handler that the callee's operations resolve against — dynamic scoping, in other words — while keeping it visible in the type. That is an attempt to buy implicit context back without giving up static reasoning, and it is the first serious proposal to do so.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

specJavaScript's this binding rules are specified: an ordinary function's this is determined by the call form (method call, plain call, new, .call/.apply/.bind), while an arrow function has no this of its own and closes over the enclosing one lexically. That is why replacing a method with an arrow function changes behavior and is not a refactoring. Perl's local and Common Lisp special variables are likewise specified dynamic binding, not implementation behavior.
implementationWhether context propagates across an asynchronous boundary is an implementation and library decision, and it differs. Python's contextvars propagate into tasks created with asyncio.create_task (the context is copied at creation) but not backwards to the creator; Node's AsyncLocalStorage propagates through most but historically not all async mechanisms; a plain threading.local() propagates to nothing at all. Test the boundary you actually use rather than assuming.
simplifiedThe two-answer example uses pseudo-code because no mainstream language lets you write the same source under both rules. Real dynamic-scope examples require a language that has it — Emacs Lisp, shell, Perl with local — and real lexical examples behave differently in ways (hoisting, closures, block scope) that would distract from the single point being made.

If you were asked this in an interview

  • Write a four-line program that prints different things under lexical and dynamic scoping, and explain each answer.
  • Name three dynamically-scoped constructs in a language you consider lexically scoped.
  • Why can closures not be implemented under dynamic scope?
  • A thread-local holds the wrong request id in production. Explain the mechanism, not just the fix.

Connections

Domains that do not exist yet
  • Programming Languages & Runtime Internals — How a runtime stores per-thread and per-task bindings, and what a read of one costs
    The compiler decides that a dynamic name needs a runtime lookup. What that lookup touches — a thread control block, a task-local map, a copied immutable context — and how fast it is belongs to the runtime.