~ / track B / clojure advanced

Dynamic vars and binding

Advanced

Most 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

ScenarioWhy dynamic is right
Request context (current user, tracing ID)Set once per request, read deep in the call stack
Database/connection handleA 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 fixtureswith-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, agent send actions: the caller's dynamic bindings are conveyed to the action.
  • A bare Thread does not convey — you must use bound-fn or capture the binding explicitly.
  • core.async/go blocks do not convey dynamic bindings reliably across parks — a major footgun. Use explicit context (a closed-over map) inside go 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 — NPE

Try it

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

Real-world

PatternWhere
clojure.core/*out*, *err*, *ns*Standard streams — rebind for capture-output tests
Logging libraries' MDCtools.logging, mulog use dynamic vars for per-request context
HTTP client libraries' default timeoutsclj-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 suitesUniversal 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:

  1. Using three ^:dynamic vars rebound at the top of the handler.
  2. Using an explicit ctx map 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?

 status: new