clojure/clojure · error · IllegalStateException

Can't dynamically bind non-dynamic var

Error message

Can't dynamically bind non-dynamic var: %s/%s

What it means

Var.pushThreadBindings (used by the binding macro) validates that every Var being bound is declared dynamic. It throws IllegalStateException naming ns/sym when a non-dynamic var is passed to binding, since only ^:dynamic vars support thread-local rebinding in modern Clojure.

Solutions

  1. Add ^:dynamic to the var's def: (def ^:dynamic *foo* default)
  2. Remove the var from the binding form and pass the value as a function argument instead
  3. If you don't own the var, use with-redefs (testing only) rather than binding
  4. Pin library versions so upstream doesn't silently drop :dynamic

Example fix

// before
(def *timeout* 100)
(binding [*timeout* 500] ...) ; IllegalStateException
// after
(def ^:dynamic *timeout* 100)
(binding [*timeout* 500] ...)
Defensive patterns

Strategy: validation

Validate before calling

(when-not (-> var meta :dynamic)
  (throw (ex-info "var is not ^:dynamic" {:var var})))

Type guard

(defn dynamic-var? [v] (boolean (:dynamic (meta v))))

Try / catch

(try (binding [v val] ...)
  (catch IllegalStateException e
    (with-redefs-fn {v (constantly val)} #(do-work))))

Prevention

When it happens

Trigger: (binding [regular-var v] ...) where the var lacks ^:dynamic; binding a var from another library that is not dynamic; a library upgrade dropped ^:dynamic from a var you bind.

Common situations: Copying binding forms referencing a var whose :dynamic metadata was removed (Clojure 1.3 made dynamic explicit); binding user-facing config vars that were never declared ^:dynamic.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of clojure/clojure@f3b143341d (2026-09-09). Data as JSON: /api/errors/fcf8bceb87f1d8ec. Report an issue: GitHub.

Appendix: source

Thrown at src/jvm/clojure/lang/Var.java:327

synchronized public Object alterRoot(IFn fn, ISeq args) {
	Object newRoot = fn.applyTo(RT.cons(root, args));
	validate(getValidator(), newRoot);
	Object oldroot = root;
	this.root = newRoot;
	++rev;
    notifyWatches(oldroot,newRoot);
	return newRoot;
}

public static void pushThreadBindings(Associative bindings){
	Frame f = dvals.get();
	Associative bmap = f.bindings;
	for(ISeq bs = bindings.seq(); bs != null; bs = bs.next())
		{
		IMapEntry e = (IMapEntry) bs.first();
		Var v = (Var) e.key();
		if(!v.dynamic)
			throw new IllegalStateException(String.format("Can't dynamically bind non-dynamic var: %s/%s", v.ns, v.sym));
		v.validate(v.getValidator(), e.val());
		v.threadBound.set(true);
		bmap = bmap.assoc(v, new TBox(Thread.currentThread(), e.val()));
		}
	dvals.set(new Frame(bmap, f));
}

public static void popThreadBindings(){
    Frame f = dvals.get().prev;
    if (f == null) {
        throw new IllegalStateException("Pop without matching push");
    } else if (f == Frame.TOP) {
        dvals.remove();
    } else {
        dvals.set(f);
    }
}

View on GitHub (pinned to f3b143341d)