clojure/clojure · error · java.lang.UnsupportedOperationException
Can only recur from tail position
Error message
Can only recur from tail position
What it means
Clojure requires that recur appear in the tail position of its enclosing loop, fn, or letfn. The compiler's RecurExpr analyzer checks that the current compile context is C.RETURN (tail position) and that LOOP_LOCALS is bound; if either fails, it throws this UnsupportedOperationException, meaning the recur would not actually be the last operation.
Solutions
- Restructure so recur is the last expression of the loop/fn body branch, e.g. wrap non-tail work before the recur call.
- Replace non-tail recur with explicit loop state updates: bind intermediate results to locals and recur once at the end.
- Use loop/recur-compatible tail style or fall back to recursion with trampolining for non-tail cases.
- If recur is inside a callback/fn literal, move it into its own loop within that fn.
Example fix
// before
(loop [x 1]
(if (< x 5)
(do (println x)
(+ 1 x) ; non-tail expression before recur
(recur x))))
// after
(loop [x 1]
(if (< x 5)
(do (println x)
(recur (inc x))) ; recur in tail position
x)) Defensive patterns
Strategy: validation
Validate before calling
;; lint before compile: recur must be last expr of loop/fn body branch
(defn recur-tail? [form]
(every? (fn [[_ body]] (= 'recur (first (last body))))
(filter vector? (rest form)))) Type guard
function valid-recur-usage? [form] (and (seq? form) (= 'recur (first form)) (loop-enclosing?))
Try / catch
try
(compile/eval form)
catch UnsupportedOperationException e
(when (.getMessage e) (throw (ex-info "non-tail recur" {:form form} e)))) Prevention
- Keep loop bodies structured as (if cond (recur new-state) result).
- Run clj-kondo or eastwood; both flag non-tail recur.
- Avoid recur inside callbacks, map/filter fns, and non-tail expressions.
When it happens
Trigger: Writing (recur ...) in a non-tail position, e.g. (if test (recur x)) missing... actually: recur inside (when ... non-tail), inside (str ... ), (map (recur ...)), after which other expressions run, or where LOOP_LOCALS is null because there is no enclosing loop/fn with matching locals.
Common situations: Putting recur in the middle of an expression like (if cond (recur x) (recur y)) is fine, but (recur (recur x)), recur inside a fn passed to map/filter, or recur inside when-not bodies that aren't tail; also recur at top level without loop.
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
- Can't eval recur
- Cannot recur across try
- Mismatched argument count to recur, expected
- ArityException
- Bad binding form, expected matched symbol expression pairs
AI-assisted analysis of clojure/clojure@f3b143341d (2026-09-09).
Data as JSON: /api/errors/2788713fd8f7380f.
Report an issue: GitHub.
Appendix: source
Thrown at src/jvm/clojure/lang/Compiler.java:7255
public Class getJavaClass() {
return RECUR_CLASS;
}
static class Parser implements IParser{
public Expr parse(C context, Object frm) {
int line = lineDeref();
int column = columnDeref();
String source = (String) SOURCE.deref();
// In :once fn, recur to head invalidates :once
ObjMethod method = (ObjMethod)METHOD.deref();
if(method.objx.onceOnly && method.clearRoot == CLEAR_ROOT.deref())
method.objx.onceOnly = false;
ISeq form = (ISeq) frm;
IPersistentVector loopLocals = (IPersistentVector) LOOP_LOCALS.deref();
if(context != C.RETURN || loopLocals == null)
throw new UnsupportedOperationException("Can only recur from tail position");
if(NO_RECUR.deref() != null)
throw new UnsupportedOperationException("Cannot recur across try");
PersistentVector args = PersistentVector.EMPTY;
for(ISeq s = RT.seq(form.next()); s != null; s = s.next())
{
args = args.cons(analyze(C.EXPRESSION, s.first()));
}
if(args.count() != loopLocals.count())
throw new IllegalArgumentException(
String.format("Mismatched argument count to recur, expected: %d args, got: %d",
loopLocals.count(), args.count()));
for(int i = 0;i< loopLocals.count();i++)
{
LocalBinding lb = (LocalBinding) loopLocals.nth(i);
Class primc = lb.getPrimitiveType();
if(primc != null)
{
boolean mismatch = false;View on GitHub (pinned to f3b143341d)