From Java/Python to Clojure - language migration guide

1 — Mental Model Shift

Clojure is a hosted Lisp on the JVM (also ClojureScript on JS, ClojureCLR on .NET). The core differences from Java/Python:

Concept
Java / Python
Clojure
Paradigm
OOP-first; objects encapsulate state
Functional-first; data + functions separate
Data
Mutable by default
Immutable by default
Evaluation
Statements + expressions
Everything is an expression
Syntax
Infix; lots of punctuation
Prefix; uniform S-expressions
Polymorphism
Inheritance / duck typing
Protocols / multimethods
Types
Static (Java) / dynamic (Python)
Dynamic; optional via clojure.spec
Key insight Clojure's answer to complexity is separating identity from state, and data from behaviour — the opposite of OOP's bundling of both into objects.

2 — Syntax Fundamentals

Clojure syntax is almost entirely uniform: (operator arg1 arg2 …). All arithmetic, function calls, control flow, and definitions share this shape.

Java
int x = 3 + 4 * 2;
String s = "hello" + " world";
boolean b = x > 5 && x < 20;
Python
x = 3 + 4 * 2
s = "hello" + " world"
b = x > 5 and x < 20
Clojure
(def x (+ 3 (* 4 2)))
(def s (str "hello" " world"))
(def b (and (> x 5) (< x 20)))

Literals

ClojureLiteral syntax
42          ; Long
3.14        ; Double
22/7        ; Ratio — exact rational, not float division
"hello"     ; String (Java String under the hood)
:keyword    ; Keyword — intern'd named value, cheap equality
'symbol     ; Symbol — names a var; not evaluated in quoted position
true false  ; Booleans
nil         ; null — also falsy like false; everything else is truthy
\a          ; Character literal
Truthiness Only nil and false are falsy. 0, "", and [] are all truthy — unlike Python.

3 — Data Structures

Clojure ships four persistent (immutable, structurally-shared) core collections. All implement the same sequence abstraction.

ClojureCore literals
[1 2 3]                  ; Vector — indexed, O(log32 n) lookup
(list 1 2 3)              ; List  — linked, fast prepend, no random access
{:a 1 :b 2}              ; Map   — hash-map by default
#{1 2 3}                 ; Set   — hash-set

Java/Python equivalents

Clojure
Java
Python
[] vector
ArrayList
list
(list)
LinkedList
collections.deque
{} map
HashMap
dict
#{} set
HashSet
set
(sorted-map)
TreeMap
dict (ordered since 3.7)

Working with maps

Python
user = {"name": "Ada", "age": 36}
user["email"] = "ada@example.com"   # mutates
name = user.get("name", "unknown")
Clojure
(def user {:name "Ada" :age 36})

; assoc returns a NEW map — user is unchanged
(def user2 (assoc user :email "ada@example.com"))

; keyword as function — preferred idiom
(:name user)                  ; => "Ada"
(get user :name "unknown")   ; with default

; nested update
(update-in user [:address :city] str " (new)")

Destructuring

Python
a, b, *rest = [1, 2, 3, 4]
name = user["name"]
Clojure
; Sequential destructuring (vectors/lists)
(let [[a b & rest] [1 2 3 4]]
  (+ a b))  ; => 3

; Map destructuring
(let [{:keys [name age]} user]
  (println name age))

; With defaults
(let [{:keys [name role] :or {role "guest"}} user]
  role)

; Works in fn args too
(defn greet [{:keys [name]}]
  (str "Hello, " name))

4 — Functions & Control Flow

Java
public static int add(int a, int b) { return a + b; }

// Variadic
public static int sum(int... nums) {
  return Arrays.stream(nums).sum();
}
Python
def add(a, b): return a + b

def sum_all(*nums): return sum(nums)
Clojure
(defn add [a b] (+ a b))

; Variadic — rest args collected into a seq
(defn sum-all [& nums] (apply + nums))

; Multi-arity
(defn greet
  ([name] (greet name "Hello"))
  ([name greeting] (str greeting ", " name)))

; Anonymous fn — two syntaxes
(filter (fn [x] (> x 2)) [1 2 3 4])
(filter #(> % 2) [1 2 3 4])  ; #() reader macro, % = first arg

Conditionals

Clojure
; if — single consequent + alternate
(if (> x 0) "positive" "non-positive")

; when — no else branch, implicit do
(when (> x 0)
  (println "positive")
  x)

; cond — like else-if chain
(cond
  (< x 0) "negative"
  (= x 0) "zero"
  :else   "positive")

; case — dispatch on value (constant time)
(case status
  :ok      "200"
  :missing "404"
  "unknown")

5 — Immutability & State

All core Clojure data structures are persistent and immutable. "Modifying" a structure returns a new version that shares structure with the old one — O(log n) via Hash Array Mapped Tries, not O(n) copy.

Python
lst = [1, 2, 3]
lst.append(4)   # mutates in place
lst[0] = 99     # mutates in place
Clojure
(def v [1 2 3])
(def v2 (conj v 4))   ; v still [1 2 3], v2 is [1 2 3 4]
(def v3 (assoc v 0 99)) ; v3 is [99 2 3], v unchanged

Managed mutable state

When you genuinely need mutation, Clojure provides explicit reference types:

ClojureReference types
; atom — uncoordinated synchronous change, most common
(def counter (atom 0))
(swap! counter inc)       ; applies fn atomically
(reset! counter 0)       ; sets absolute value
@counter                  ; deref — current value

; ref — coordinated change via STM transactions
(def account-a (ref 100))
(def account-b (ref 200))
(dosync
  (alter account-a - 50)
  (alter account-b + 50))  ; atomic across both refs

; agent — async, off-thread updates
(def logger (agent []))
(send logger conj "event")

6 — Collection Operations

Clojure's collection functions operate on the sequence abstraction. Any seqable (vectors, lists, maps, sets, strings, Java iterables) works with the same functions.

Python
nums = [1, 2, 3, 4, 5]
doubled  = list(map(lambda x: x * 2, nums))
evens    = list(filter(lambda x: x % 2 == 0, nums))
total    = sum(nums)
grouped  = {k: list(v) for k, v in
            itertools.groupby(nums, key=lambda x: x % 2)}
Clojure
(def nums [1 2 3 4 5])

(map    #(* % 2) nums)      ; => (2 4 6 8 10)  lazy seq
(filter even? nums)         ; => (2 4)
(reduce + nums)            ; => 15
(group-by even? nums)      ; => {false [1 3 5], true [2 4]}

; Transducers — composable, no intermediate collections
(transduce
  (comp (filter even?) (map #(* % 2)))
  +
  nums)  ; => 12

Thread macros (pipeline syntax)

Replaces deeply nested calls or method chaining:

Java (streams)
list.stream()
    .filter(x -> x % 2 == 0)
    .map(x -> x * 3)
    .collect(Collectors.toList());
Clojure
; ->> threads value as LAST arg
(->> nums
     (filter even?)
     (map #(* % 3))
     (into []))   ; => [6 12]

; -> threads as FIRST arg (useful for map/record ops)
(-> user
    (assoc :role "admin")
    (dissoc :password)
    (update :name str/upper-case))

7 — Java Interop

Clojure runs on the JVM. All Java classes are available without imports for java.lang.*; others require :import.

ClojureCalling Java
; Static method: (ClassName/method args)
(Math/sqrt 16.0)              ; => 4.0
(System/currentTimeMillis)

; Instance method: (.method obj args)
(def sb (StringBuilder.))      ; constructor — note dot suffix
(.append sb "hello")
(.toString sb)

; Field access
(.length "hello")              ; => 5

; Chained — use doto for side effects on one object
(doto (java.util.HashMap.)
  (.put "a" 1)
  (.put "b" 2))

; Import in namespace
(ns my.app
  (:import [java.util Date UUID]
            [java.io File]))

Type hints (avoid reflection)

Clojure
; Without hint: runtime reflection (slow in hot paths)
(defn greet [s]
  (.toUpperCase s))

; With hint: direct bytecode method call
(defn greet [^String s]
  (.toUpperCase s))

Implementing Java interfaces

Clojure
; reify — anonymous one-off implementation
(def runnable
  (reify Runnable
    (run [this] (println "running"))))

; proxy — when you need to extend a class
(proxy [java.util.TimerTask] []
  (run [] (println "tick")))
From Python Python has no JVM. Clojure's Java interop is irrelevant to CPython developers, but if you're coming from Jython or PyPy, the interop model is familiar. Most Python→Clojure migrations use Clojure's own ecosystem rather than wrapping Java libs.

8 — Namespaces & Deps

Java
package com.example.app;
import java.util.List;
import static java.util.Collections.sort;
Python
from os.path import join
import collections as col
Clojure
(ns com.example.app
  (:require [clojure.string :as str]
            [clojure.set :as set]
            [my.other.ns :refer [specific-fn]])
  (:import  [java.util Date]))

; Use
(str/upper-case "hello")
(set/union #{1 2} #{2 3})

deps.edn (Clojure CLI)

deps.edn
{:deps {org.clojure/clojure       {:mvn/version "1.12.0"}
         metosin/malli              {:mvn/version "0.16.4"}
         com.github.seancorfield/next.jdbc {:mvn/version "1.3.939"}}
 :paths ["src" "resources"]
 :aliases
 {:dev  {:extra-paths ["dev"]
          :extra-deps  {nrepl/nrepl {:mvn/version "1.3.0"}}}
  :test {:extra-paths ["test"]}}}

9 — Concurrency

Java
AtomicInteger counter = new AtomicInteger(0);
ExecutorService pool = Executors.newFixedThreadPool(4);
Future<int> f = pool.submit(() -> expensiveOp());
int result = f.get();
Python
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=4) as ex:
    f = ex.submit(expensive_op)
    result = f.result()
Clojure
; future — runs on a thread pool, deref blocks
(def f (future (expensive-op)))
@f   ; blocks until done

; pmap — parallel map over collection
(pmap expensive-op large-list)

; core.async — CSP channels (like Go channels)
(require '[clojure.core.async :as a])
(def ch (a/chan 10))
(a/go
  (a/>! ch "message"))
(a/go
  (let [msg (a/<! ch)]
    (println msg)))
Why it's safer Because data is immutable, threads never race over shared mutable state. Atoms use compare-and-swap under the hood. Refs provide STM — multi-var transactions without locks.

10 — Common Patterns

Exception handling

Clojure
(try
  (/ 1 0)
  (catch ArithmeticException e
    (println "caught:" (.getMessage e)))
  (catch Exception e
    (throw e))
  (finally
    (println "always runs")))

Protocols (like interfaces)

Java interface
interface Describable {
  String describe();
}
Clojure
(defprotocol Describable
  (describe [this]))

; Implement for a record
(defrecord Dog [name breed]
  Describable
  (describe [_] (str name " the " breed)))

; Extend an existing type retroactively (open extension)
(extend-protocol Describable
  String
  (describe [s] (str "String: " s)))

Multimethods (open dispatch)

Clojure
(defmulti  area :shape)  ; dispatch on :shape key

(defmethod area :circle  [{:keys [r]}]
  (* Math/PI r r))
(defmethod area :rect    [{:keys [w h]}]
  (* w h))

(area {:shape :circle :r 5})   ; => 78.54...

Spec (runtime validation)

Clojureclojure.spec.alpha
(require '[clojure.spec.alpha :as s])

(s/def ::age  (s/and int? #(>= % 0)))
(s/def ::name (s/and string? #(seq %)))
(s/def ::user (s/keys :req-un [::name ::age]))

(s/valid? ::user {:name "Ada" :age 36})  ; => true
(s/explain ::user {:name "" :age -1})  ; prints failures

; Instrument a fn — validates args at call time
(s/fdef greet
  :args (s/cat :user ::user)
  :ret  string?)

Macros (metaprogramming)

Clojure macros operate on code-as-data at read time. Unlike Java annotations or Python decorators, they can introduce entirely new control flow.

Clojure
; Writing a simple unless macro
(defmacro unless [test & body]
  `(when (not ~test) ~@body))

(unless (empty? items)
  (process! items))

; macroexpand to debug what a macro produces
(macroexpand-1 '(unless false (println "hi")))
Macro caveat Reach for macros only when a function won't do. Most Clojure code uses zero custom macros — the built-in ones (->, ->>, when, cond, etc.) cover the vast majority of cases.

Quick reference: idiom mapping

Intent
Java / Python
Clojure
Null check
x != null / x is not None
(some? x) / (nil? x)
Type check
x instanceof Foo / isinstance(x, Foo)
(instance? Foo x)
String format
String.format() / f"..."
(format "..." args)
Print
System.out.println / print()
(println ...)
Loop + index
for (int i…) / enumerate()
(map-indexed f coll)
Range
IntStream.range(0,10) / range(10)
(range 10)
Last item
list.get(list.size()-1) / lst[-1]
(last coll) or (peek v)
Mutable loop
for / while
(loop [x init] (recur ...))
Dict merge
{**a, **b} (Python 3.5+)
(merge a b) / (merge-with + a b)
REPL eval
jshell / python -i
clj — nREPL for editor integration