~ / track B / clojure advanced
Dynamic vars and binding
AdvancedMost Clojure vars are constant — once you defclojure.core/defBind a name to a value in the current namespace.view on clojuredocs → 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 bindingclojure.core/bindingDynamic, thread-local rebinding of dynamic vars.view on clojuredocs → macro — 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* (the asterisks around the name) are a Clojure naming
convention — there's no mechanism behind them, just a visual marker so
readers know the var is meant to be rebound.
bindingclojure.core/bindingDynamic, thread-local rebinding of dynamic vars.view on clojuredocs → 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 hereInside the bindingclojure.core/bindingDynamic, thread-local rebinding of dynamic vars.view on clojuredocs → 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
bindingclojure.core/bindingDynamic, thread-local rebinding of dynamic vars.view on clojuredocs →, that thread does not automatically inherit the rebinding
unless you use bound-fnclojure.core/bound-fnWrap a fn so it inherits the caller's dynamic var bindings on each call.view on clojuredocs → 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-redefsclojure.core/with-redefsTemporarily replace root bindings — useful in tests.view on clojuredocs → 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-redefsclojure.core/with-redefsTemporarily replace root bindings — useful in tests.view on clojuredocs → — testing only
For tests, with-redefsclojure.core/with-redefsTemporarily replace root bindings — useful in tests.view on clojuredocs → 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-redefsclojure.core/with-redefsTemporarily replace root bindings — useful in tests.view on clojuredocs → 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:
- futureclojure.core/futureRun body on a thread pool; deref blocks until result is ready.view on clojuredocs →,
pmap, agentclojure.core/agentAsynchronous, uncoordinated reference type; updated via send/send-off.view on clojuredocs → send actions: the caller's dynamic bindings are conveyed to the action. - A bare
Threaddoes not convey — you must use bound-fnclojure.core/bound-fnWrap a fn so it inherits the caller's dynamic var bindings on each call.view on clojuredocs → or 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) inside goclojure.core.async/goSpawn a parked-coroutine block backed by the async thread pool.view on clojuredocs → blocks.
(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 — NPETry 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-redefsclojure.core/with-redefsTemporarily replace root bindings — useful in tests.view on clojuredocs → 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?