Python to Clojure: A Gentle Guide for Pythonistas
Python is the world's most popular general-purpose language. Clojure is a quiet powerhouse — a modern Lisp on the JVM that has earned fierce loyalty among developers who discover it. Both languages prize simplicity, but they mean very different things by the word.
This article walks through every major topic in the official Python tutorial and shows how Clojure approaches the same idea. Whether you're a Pythonista curious about functional programming or a polyglot looking for a side-by-side reference, this guide is for you.
1. Whetting Your Appetite
Python sells itself on readability, rapid prototyping, and "batteries included." It eliminates the compile-link cycle, encourages experimentation in the REPL, and runs everywhere.
Clojure shares the same REPL-driven culture, but takes a more opinionated stance: data is the universal interface. Where Python says "everything is an object," Clojure says "everything is data." Clojure runs on the JVM (and in the browser via ClojureScript), giving it access to the entire Java ecosystem — a different kind of "batteries included."
Both languages let you sit down and be productive in minutes. The difference is in what they optimize for: Python optimizes for readability of imperative code; Clojure optimizes for simplicity of data transformation.
2. The Interpreter / The REPL
Python has an interactive interpreter invoked with python:
>>> 2 + 2
4
>>> print("Hello, world!")
Hello, world!
Clojure has the REPL (Read-Eval-Print Loop), which serves the same purpose but is even more central to the workflow. Most Clojure developers keep a REPL connected to their editor at all times:
user=> (+ 2 2)
4
user=> (println "Hello, world!")
Hello, world!
The parentheses aren't noise — they're the syntax. Every expression is a list where the first element is the function. This uniformity is what makes Lisp macros possible, and it takes about a day to stop noticing the parens.
3. An Informal Introduction
Numbers
Python:
>>> 17 / 3 # 5.666...
>>> 17 // 3 # 5 (floor division)
>>> 17 % 3 # 2
>>> 5 ** 2 # 25
Clojure:
(/ 17 3) ;=> 17/3 (a rational! no precision lost)
(quot 17 3) ;=> 5
(rem 17 3) ;=> 2
(Math/pow 5 2) ;=> 25.0
Right away, a philosophical difference appears. Clojure gives you a ratio (17/3) by default rather than silently losing precision. Clojure also has arbitrary-precision integers out of the box — no special int vs long distinction to worry about.
Strings
Python:
word = "Python"
word[0] # 'P'
word[0:2] # 'Py'
len(word) # 6
f"Hello, {word}!"
Clojure:
(def word "Clojure")
(get word 0) ;=> \C (a character)
(subs word 0 2) ;=> "Cl"
(count word) ;=> 7
(str "Hello, " word "!")
Both treat strings as immutable. In Python, strings are sequences of characters; in Clojure, strings are Java strings, but you can also treat them as sequences of characters when needed via seq.
Lists
Python:
squares = [1, 4, 9, 16, 25]
squares[0] # 1
squares + [36, 49] # [1, 4, 9, 16, 25, 36, 49]
squares.append(36) # mutates!
Clojure:
(def squares [1 4 9 16 25])
(get squares 0) ;=> 1
(conj squares 36) ;=> [1 4 9 16 25 36] (new vector!)
(into squares [36 49]) ;=> [1 4 9 16 25 36 49]
This is the first big fork in the road. Python lists are mutable; Clojure vectors are persistent and immutable. conj doesn't change squares — it returns a new vector that efficiently shares structure with the old one. No defensive copying, no "did someone mutate my list?" bugs.
4. Control Flow
if / cond
Python:
if x < 0:
print("Negative")
elif x == 0:
print("Zero")
else:
print("Positive")
Clojure:
(cond
(neg? x) (println "Negative")
(zero? x) (println "Zero")
:else (println "Positive"))
In Clojure, if is an expression that returns a value (no need for a ternary operator). cond handles the multi-branch case.
for loops
Python:
for word in ["cat", "window", "defenestrate"]:
print(word, len(word))
Clojure:
(doseq [word ["cat" "window" "defenestrate"]]
(println word (count word)))
But here's the thing — doseq is for side effects. When you want to transform data (which is most of the time), you use map, filter, or for (which is a list comprehension, not a loop):
(for [word ["cat" "window" "defenestrate"]]
[word (count word)])
;=> (["cat" 3] ["window" 6] ["defenestrate" 13])
range
Python:
list(range(0, 10, 2)) # [0, 2, 4, 6, 8]
Clojure:
(range 0 10 2) ;=> (0 2 4 6 8)
Clojure's range is lazy — it doesn't allocate memory for all elements upfront.
Pattern Matching
Python 3.10+:
match status:
case 400:
return "Bad request"
case 401 | 403:
return "Not allowed"
case _:
return "Something's wrong"
Clojure (with core.match):
(match status
400 "Bad request"
(:or 401 403) "Not allowed"
:else "Something's wrong")
Clojure's pattern matching via core.match is a library, not built-in syntax — which is itself a philosophical statement. In Lisp, you can add any syntax you want via macros.
Functions
Python:
def fib(n):
"""Return Fibonacci series up to n."""
a, b = 0, 1
result = []
while a < n:
result.append(a)
a, b = b, a + b
return result
Clojure:
(defn fib [n]
"Return Fibonacci series up to n."
(->> [0 1]
(iterate (fn [[a b]] [b (+ a b)]))
(map first)
(take-while #(< % n))))
The Clojure version reads as a pipeline: start with [0 1], repeatedly produce the next pair, extract the first element, and keep going while it's less than n. No mutation, no accumulator variable, no while loop. Just data flowing through transformations.
Default Arguments, *args, **kwargs
Python:
def greet(name, greeting="Hello"):
print(f"{greeting}, {name}!")
def log(*args, **kwargs):
print(args, kwargs)
Clojure:
(defn greet
([name] (greet name "Hello"))
([name greeting] (println (str greeting ", " name "!"))))
(defn log [& args]
(println args))
Clojure uses multi-arity functions for defaults and & args for variadics. For keyword arguments, the idiomatic approach is to pass a map:
(defn create-user [{:keys [name email role] :or {role "member"}}]
{:name name :email email :role role})
(create-user {:name "Ada" :email "ada@example.com"})
Lambda Expressions
Python:
double = lambda x: x * 2
sorted(pairs, key=lambda p: p[1])
Clojure:
(def double #(* % 2)) ; reader macro shorthand
(sort-by second pairs) ; or: (sort-by #(nth % 1) pairs)
In Clojure, anonymous functions are so common they have a shorthand: #(...) with % for the argument. Named functions and anonymous functions are treated identically — there's no second-class lambda.
5. Data Structures
Lists, Tuples, and Vectors
| Python | Clojure | Properties |
|---|---|---|
list — [1, 2, 3]
|
vector — [1 2 3]
|
Indexed, ordered |
tuple — (1, 2, 3)
|
vector — [1 2 3]
|
(Clojure vectors are already immutable) |
| — |
list — '(1 2 3)
|
Linked list, efficient head access |
Python needs both lists (mutable) and tuples (immutable). Clojure needs only vectors (immutable, indexed) and lists (immutable, sequential). The mutability question simply doesn't arise.
Dictionaries / Maps
Python:
tel = {"jack": 4098, "sape": 4139}
tel["guido"] = 4127
del tel["sape"]
{x: x**2 for x in range(5)}
Clojure:
(def tel {"jack" 4098 "sape" 4139})
(assoc tel "guido" 4127) ;=> new map with guido added
(dissoc tel "sape") ;=> new map without sape
(into {} (map (fn [x] [x (* x x)]) (range 5)))
Again: assoc and dissoc return new maps. The original is untouched. Clojure maps can also use keywords as keys (:jack instead of "jack"), which double as accessor functions — a small but delightful ergonomic touch:
(def person {:name "Ada" :age 36})
(:name person) ;=> "Ada"
Sets
Python:
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
a - b # {1, 2}
a | b # {1, 2, 3, 4, 5, 6}
a & b # {3, 4}
Clojure:
(require '[clojure.set :as set])
(def a #{1 2 3 4})
(def b #{3 4 5 6})
(set/difference a b) ;=> #{1 2}
(set/union a b) ;=> #{1 2 3 4 5 6}
(set/intersection a b) ;=> #{3 4}
Virtually identical semantics, different syntax. Clojure sets are also immutable and can be used as functions: (a 3) returns 3 (truthy), (a 7) returns nil (falsy).
List Comprehensions
Python:
[x**2 for x in range(10) if x % 2 == 0]
Clojure:
(for [x (range 10) :when (even? x)]
(* x x))
Clojure's for is a list comprehension, not a loop. It supports :when for filtering, :let for local bindings, and multiple binding forms for nested iteration — all lazily evaluated.
Looping Techniques
Python:
for i, v in enumerate(["tic", "tac", "toe"]):
print(i, v)
for q, a in zip(questions, answers):
print(q, a)
Clojure:
(doseq [[i v] (map-indexed vector ["tic" "tac" "toe"])]
(println i v))
(doseq [[q a] (map vector questions answers)]
(println q a))
Or more idiomatically, just use map to transform and print later:
(map-indexed (fn [i v] [i v]) ["tic" "tac" "toe"])
(map vector questions answers)
6. Modules and Namespaces
Python:
import math
from os.path import join
import numpy as np
Clojure:
(require '[clojure.string :as str])
(require '[clojure.java.io :as io])
(import '[java.util Date])
Python organizes code into modules (files) and packages (directories with __init__.py). Clojure organizes code into namespaces — each file declares a namespace with ns, and dependencies are explicit:
(ns myapp.core
(:require [clojure.string :as str]
[cheshire.core :as json])
(:import [java.time LocalDate]))
There's no from X import * equivalent in Clojure, and that's by design. Explicit is better than implicit — a principle Pythonistas already appreciate.
The if __name__ == "__main__" Pattern
Python:
if __name__ == "__main__":
main()
Clojure doesn't need this pattern because the REPL workflow means you rarely "run a file." When you do need an entry point, you declare a -main function:
(defn -main [& args]
(println "Hello from the command line!"))
7. Input and Output
String Formatting
Python:
name = "World"
f"Hello, {name}!"
"{:.2f}".format(3.14159)
Clojure:
(str "Hello, " name "!") ; simple concatenation
(format "Hello, %s!" name) ; printf-style
(format "%.2f" 3.14159) ; "3.14"
Clojure uses Java's String.format under the hood. There's no f-string equivalent, but str for concatenation and format for templates cover the same ground.
File I/O
Python:
with open("data.txt", encoding="utf-8") as f:
content = f.read()
with open("output.txt", "w", encoding="utf-8") as f:
f.write("Hello\n")
Clojure:
(slurp "data.txt") ; read entire file
(spit "output.txt" "Hello\n") ; write entire file
;; For line-by-line processing:
(with-open [rdr (clojure.java.io/reader "data.txt")]
(doseq [line (line-seq rdr)]
(println line)))
slurp and spit — arguably the best-named I/O functions in any language. For structured work, with-open mirrors Python's with statement.
JSON
Python:
import json
data = json.loads('{"name": "Ada"}')
json.dumps(data)
Clojure (with cheshire or clojure.data.json):
(require '[cheshire.core :as json])
(json/parse-string "{\"name\": \"Ada\"}" true) ;=> {:name "Ada"}
(json/generate-string {:name "Ada"}) ;=> "{\"name\":\"Ada\"}"
The true argument keywordizes the keys — JSON maps become idiomatic Clojure maps instantly.
8. Errors and Exceptions
Try / Catch
Python:
try:
x = int(input("Number: "))
except ValueError as e:
print(f"Invalid: {e}")
finally:
print("Done")
Clojure:
(try
(Integer/parseInt (read-line))
(catch NumberFormatException e
(println "Invalid:" (.getMessage e)))
(finally
(println "Done")))
Since Clojure runs on the JVM, it catches Java exceptions. The structure is almost identical to Python's try/except/finally.
Raising Exceptions
Python:
raise ValueError("something went wrong")
Clojure:
(throw (ex-info "something went wrong" {:type :validation}))
ex-info is idiomatic Clojure — it creates an exception that carries a data map. Instead of defining custom exception classes, you attach structured data to a generic exception. This is the "data over classes" philosophy in action:
(try
(throw (ex-info "bad input" {:field :email :value "not-an-email"}))
(catch clojure.lang.ExceptionInfo e
(println (ex-data e))))
;=> {:field :email, :value "not-an-email"}
Custom Exceptions
Python:
class InsufficientFunds(Exception):
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
Clojure rarely defines custom exception classes. Instead:
(throw (ex-info "Insufficient funds"
{:type :insufficient-funds
:balance 100
:amount 150}))
One generic mechanism, infinite data shapes. No class hierarchy to maintain.
9. Classes and Objects
This is where Python and Clojure diverge most dramatically.
Python's Object-Oriented Approach
class Dog:
kind = "canine" # class variable
def __init__(self, name):
self.name = name # instance variable
def speak(self):
return f"{self.name} says woof!"
rex = Dog("Rex")
rex.speak() # "Rex says woof!"
Clojure's Data-Oriented Approach
(def rex {:kind "canine" :name "Rex"})
(defn speak [dog]
(str (:name dog) " says woof!"))
(speak rex) ;=> "Rex says woof!"
No class. No self. No constructor. Just a map and a function. The dog is its data.
Inheritance vs. Composition
Python:
class Animal:
def __init__(self, name):
self.name = name
class Dog(Animal):
def speak(self):
return "Woof!"
class Cat(Animal):
def speak(self):
return "Meow!"
Clojure uses multimethods or protocols for polymorphism:
;; Multimethod approach — dispatch on data
(defmulti speak :type)
(defmethod speak :dog [animal] "Woof!")
(defmethod speak :cat [animal] "Meow!")
(speak {:type :dog :name "Rex"}) ;=> "Woof!"
(speak {:type :cat :name "Whiskers"}) ;=> "Meow!"
;; Protocol approach — like interfaces
(defprotocol Speakable
(speak [this]))
(defrecord Dog [name]
Speakable
(speak [this] (str name " says Woof!")))
(speak (->Dog "Rex")) ;=> "Rex says Woof!"
Multimethods dispatch on any function of the arguments (not just type), making them more flexible than traditional OOP dispatch.
Iterators and Generators
Python:
class Reverse:
def __init__(self, data):
self.data = data
self.index = len(data)
def __iter__(self):
return self
def __next__(self):
if self.index == 0:
raise StopIteration
self.index -= 1
return self.data[self.index]
def reverse(data):
for index in range(len(data) - 1, -1, -1):
yield data[index]
Clojure:
(reverse "spam") ;=> (\m \a \p \s)
(rseq [1 2 3 4]) ;=> (4 3 2 1)
Clojure's sequences are lazy iterators. There's no iterator protocol to implement — every collection already participates in the sequence abstraction. Need a custom lazy sequence? Use lazy-seq:
(defn countdown [n]
(when (pos? n)
(lazy-seq (cons n (countdown (dec n))))))
(countdown 5) ;=> (5 4 3 2 1)
Generator Expressions
Python:
sum(x*x for x in range(10))
Clojure:
(reduce + (map #(* % %) (range 10)))
;; or with the threading macro:
(->> (range 10) (map #(* % %)) (reduce +))
The threading macro ->> reads like a Unix pipeline and is one of Clojure's most beloved features.
10. The Standard Library
Python's "Batteries Included"
Python ships with modules for OS interaction, file I/O, regex, math, dates, HTTP, email, testing, logging, threading, and much more — all in the standard library.
Clojure's Approach
Clojure's standard library is smaller but remarkably powerful for data manipulation. For everything else, you tap into:
-
The entire Java ecosystem — need HTTP? Use
java.net.http. Need dates? Usejava.time. Need crypto? Usejavax.crypto. -
Clojure community libraries — managed via
deps.ednor Leiningen.
| Python Module | Clojure Equivalent |
|---|---|
os, shutil
|
clojure.java.io, Java NIO |
re |
re-find, re-matches, re-seq (built-in) |
math |
clojure.math (1.11+), java.lang.Math
|
datetime |
java.time (via tick library for ergonomics) |
json |
clojure.data.json or cheshire
|
unittest |
clojure.test (built-in) |
logging |
tools.logging + logback
|
threading |
core.async, future, pmap, agents |
collections |
Built into the core (persistent data structures) |
argparse |
tools.cli |
sqlite3 |
next.jdbc + any JDBC driver |
Concurrency — Where Clojure Truly Shines
Python's threading story involves the GIL and careful locking. Clojure was designed for concurrency from day one:
- Atoms — uncoordinated, synchronous state updates
- Refs — coordinated, transactional state (Software Transactional Memory)
- Agents — asynchronous state updates
- core.async — CSP-style channels (like Go's goroutines)
(def counter (atom 0))
(swap! counter inc) ;=> 1 (thread-safe, no locks)
@counter ;=> 1
;; Process 1000 items in parallel:
(pmap expensive-fn (range 1000))
11. Advanced Standard Library
Output Formatting
Python: pprint, textwrap, locale
Clojure: clojure.pprint/pprint (built-in), Java's Locale
(require '[clojure.pprint :refer [pprint]])
(pprint {:a 1 :b {:c [1 2 3] :d "hello"}})
Templating
Python: string.Template
Clojure: clojure.core/format, or libraries like Selmer for HTML templating.
Multi-threading
Python:
import threading
t = threading.Thread(target=worker)
t.start()
Clojure:
(future (worker)) ; runs in a thread pool, returns a deref-able future
@(future (+ 1 2)) ;=> 3
Logging
Python: logging.warning("Watch out!")
Clojure:
(require '[clojure.tools.logging :as log])
(log/warn "Watch out!")
Decimal Arithmetic
Python: decimal.Decimal("0.1") + decimal.Decimal("0.2")
Clojure:
(+ 0.1M 0.2M) ;=> 0.3M (BigDecimal literal with M suffix)
Clojure has literal syntax for BigDecimals (M suffix) and BigIntegers (N suffix) — no imports needed.
12. Virtual Environments and Packages
Python uses venv and pip:
python -m venv myenv
source myenv/bin/activate
pip install requests
pip freeze > requirements.txt
Clojure uses deps.edn (official) or project.clj (Leiningen):
;; deps.edn
{:deps {org.clojure/clojure {:mvn/version "1.12.0"}
cheshire/cheshire {:mvn/version "5.13.0"}}}
clj -M -m myapp.core # run with dependencies resolved automatically
There's no virtual environment concept because dependencies are resolved per-project from the deps.edn file. Maven coordinates ensure reproducibility. No activate/deactivate dance.
13. Floating-Point Arithmetic
Both languages sit on top of IEEE 754 doubles:
>>> 0.1 + 0.2 == 0.3
False
(= (+ 0.1 0.2) 0.3) ;=> false
Same hardware, same surprise. But Clojure offers escape hatches as first-class citizens:
;; Ratios — exact arithmetic, no precision loss
(+ 1/10 2/10) ;=> 3/10
(= (+ 1/10 2/10) 3/10) ;=> true
;; BigDecimals
(+ 0.1M 0.2M) ;=> 0.3M
Clojure's ratio type means you can do exact fractional arithmetic without importing anything. This alone has saved countless financial applications from rounding bugs.
14. Interactive Editing
Python supports readline-based history and tab completion in its interactive interpreter.
Clojure developers typically use nREPL connected to their editor (Emacs + CIDER, VS Code + Calva, IntelliJ + Cursive). The experience goes far beyond line editing — you can evaluate any expression in your source file, inspect results inline, and navigate documentation without leaving your editor.
The Big Picture
| Dimension | Python | Clojure |
|---|---|---|
| Paradigm | Multi-paradigm (imperative + OOP + functional) | Functional-first (with pragmatic escape hatches) |
| Mutability | Mutable by default | Immutable by default |
| Type System | Dynamic, gradual typing via hints | Dynamic, with optional specs |
| Concurrency | GIL, async/await, multiprocessing | STM, atoms, agents, core.async |
| Syntax | Indentation-based, keyword-rich | S-expressions, minimal syntax |
| OOP | Classes, inheritance, dunder methods | Protocols, multimethods, plain maps |
| Runtime | CPython, PyPy | JVM, JavaScript (ClojureScript) |
| Package Manager | pip + venv | deps.edn / Leiningen + Maven |
| REPL Culture | Strong | Even stronger |
| Ideal For | Scripts, ML/AI, web, automation | Data processing, concurrency, web, DSLs |
Closing Thoughts
Python is a phenomenal language. Its readability, ecosystem, and community have earned it the top spot for good reason. But if you've ever felt the friction of debugging shared mutable state, wrestling with class hierarchies, or wishing your data transformations could be simpler — Clojure might be the language that makes you see programming differently.
You don't have to abandon Python. Many developers use both: Python for its unmatched ML/AI ecosystem and scripting ergonomics, Clojure for systems where correctness, concurrency, and data transformation matter most.
The best way to start is to fire up a REPL. Try Clojure's official getting started guide, or experiment in the browser at repl.it. The parentheses will feel strange for an hour. Then they'll feel like home.
This article was written as a companion to the official Python tutorial. Every section maps to a chapter in that tutorial, translated through the lens of Clojure's philosophy: simple, data-driven, and functional.
Top comments (0)