~ / track B / clojure advanced
Dynamic vars and binding
AdvancedMost Clojure vars are constant — once you def them, they hold the
same value forever (until you redefine them). A dynamic var is
different: it can be rebound, per-thread, for the duration of a scope, via
the binding macro. This is Clojure's controlled escape hatch for
context-passing: logger, request, current-user, transaction handle.
Marking a var as dynamic
(def ^:dynamic *db* nil)
(def ^:dynamic *log-level* :info)
The ^:dynamic metadata makes the var thread-local-rebindable. The
*earmuffs* are a Clojure convention — there's no mechanism behind them,
just a visual marker so readers know the var is meant to be rebound.
binding establishes a dynamic scope
(defn query [sql] (.execute *db* sql))
(binding [*db* (open-connection url)]
(query "select 1")
(query "select 2"))
;; *db* is back to nil here
Inside the binding form, all calls — including in functions invoked from
inside — see the rebound value. When the form exits, the binding stack
pops. Each thread has its own stack: if you spawn a thread inside
binding, that thread does not automatically inherit the rebinding
unless you use bound-fn or (future ...)-with-conveyance.
When dynamic vars pay off
| Scenario | Why dynamic is right |
|---|---|
| Request context (current user, tracing ID) | Set once per request, read deep in the call stack |
| Database/connection handle | A whole chain of helpers can call (query ...) without each taking db as an argument |
| Logging level or output stream | *out*, *err*, *ns* in core Clojure are dynamic for this reason |
| Test fixtures | with-redefs and bindings for stub responses |
When not to use them
Dynamic vars are a form of global mutable state, even if scoped. They're
hard to refactor away once load-bearing, and threads/async make them error-
prone. The modern alternative for service-level dependencies is
[[component-systems]]: pass an explicit system map. Dynamic vars stay
for things that really are ambient (logger config, the current request
trace ID).
with-redefs — testing only
For tests, with-redefs rebinds any var (not just ^:dynamic ones) for
the dynamic extent of a form. Useful for stubbing pure functions and
external calls:
(deftest fetches-user
(with-redefs [http/get (fn [_url] {:status 200 :body "{\"id\":1}"})]
(is (= 1 (-> (fetch-user 1) :id)))))
Caveats: with-redefs is global per JVM, not per thread, so tests using
it must not run in parallel without care. And don't ship code that uses it
outside test contexts — it's a sledgehammer.
Thread conveyance
Clojure handles thread inheritance for dynamic bindings in three places:
future,pmap,agentsend actions: the caller's dynamic bindings are conveyed to the action.- A bare
Threaddoes not convey — you must usebound-fnor capture the binding explicitly. core.async/goblocks do not convey dynamic bindings reliably across parks — a major footgun. Use explicit context (a closed-over map) insidegoblocks.
(binding [*db* my-conn]
(future (query "select 1"))) ;; *db* is bound inside the future
(binding [*db* my-conn]
(.start (Thread. (fn [] (query "select 1"))))) ;; NOT bound — NPE
Try it
Real-world
| Pattern | Where |
|---|---|
clojure.core/*out*, *err*, *ns* | Standard streams — rebind for capture-output tests |
| Logging libraries' MDC | tools.logging, mulog use dynamic vars for per-request context |
| HTTP client libraries' default timeouts | clj-http *default-timeout* style |
Datomic's *db* and *connection* | Set once per request handler |
clojure.test/*report-counters* | The test framework itself is built on dynamic vars |
| Tracing libraries (OpenTelemetry wrappers) | Current span lives in a dynamic var per thread |
with-redefs in test suites | Universal mocking idiom |
Check yourself
? quiz
A function reads `*db*` deep in its call chain. You set `(binding [*db* conn] (do-work))`. Inside `do-work`, you spawn a bare `Thread` that calls a helper that uses `*db*`. What value does the helper see?
Exercise
Take a function (defn handle-request [req] ...) that needs three pieces of
ambient context: current-user, db-conn, request-id. Sketch two
versions:
- Using three
^:dynamicvars rebound at the top of the handler. - Using an explicit
ctxmap threaded through every function.
For each, list one situation where it makes refactoring easier and one where it makes refactoring harder. Which would you pick for a brand-new service? For a legacy ten-year-old one?