Python to Basilisp: Clojure's Brain in Python's Body
title: "Python to Basilisp: Clojure's Brain in Python's Body" published: true description: "A comprehensive comparison of Python and Basilisp — the Clojure dialect that compiles to Python bytecode. Immutable data structures, macros, and the entire PyPI ecosystem, side by side with every topic in the official Python tutorial." tags: python, clojure, functional, beginners
cover_image:
Python to Basilisp: Clojure's Brain in Python's Body
What if you could have Clojure's immutable data structures, its elegant sequence abstractions, its macros and protocols — but running on the Python VM with full access to PyPI?
That's Basilisp. It's a Clojure-compatible Lisp dialect that compiles to Python 3 bytecode. Not a bridge, not a transpiler with gaps — Basilisp code becomes Python bytecode and runs on CPython, with seamless interop in both directions. You can import NumPy into your Basilisp code or require a Basilisp namespace from Python.
If Hy is "Python wearing Lisp syntax," Basilisp is "Clojure wearing Python's shoes." The syntax comes from Clojure. The semantics — immutability, persistent data structures, atoms, protocols, transducers — come from Clojure. But the runtime, the libraries, and the deployment story are all Python.
This article walks through every major topic in the official Python tutorial and shows how Basilisp handles the same concept. If you know Python, you're already halfway there. If you know Clojure, you're home.
1. Whetting Your Appetite
Python sells itself on readability, rapid prototyping, and the largest package ecosystem in the world.
Basilisp inherits Python's ecosystem — literally, via pip install — while adding what Python lacks:
- Immutable data structures by default, backed by efficient persistent implementations
- First-class sequence abstractions that unify iteration across every data type
- Macros for compile-time code generation
- Atoms for safe, lock-free concurrent state
- Protocols and multimethods for polymorphism without inheritance
Install with pip install basilisp and type basilisp repl. You're in.
2. The Interpreter / The REPL
Python:
>>> 2 + 2
4
>>> print("Hello, world!")
Hello, world!
Basilisp:
basilisp.user=> (+ 2 2)
4
basilisp.user=> (println "Hello, world!")
Hello, world!
nil
The REPL is central to Basilisp's workflow, just as it is in Clojure. Every expression returns a value — println prints the string and returns nil (Basilisp's equivalent of Python's None).
The parentheses aren't decoration — they're the syntax. The first element of every list is the function; the rest are arguments. This uniformity is what makes macros possible.
3. An Informal Introduction
Numbers
Python:
>>> 17 / 3 # 5.666...
>>> 17 // 3 # 5
>>> 17 % 3 # 2
>>> 5 ** 2 # 25
Basilisp:
(/ 17 3) ;=> 17/3 (a Fraction — exact!)
(quot 17 3) ;=> 5
(rem 17 3) ;=> 2
(Math/pow 5 2) ;=> 25.0
;; or simply:
(* 5 5) ;=> 25
The first surprise: (/ 17 3) returns 17/3 — a Python fractions.Fraction object. No precision lost. Basilisp inherits Clojure's preference for exact arithmetic by default.
Operators accept multiple arguments naturally:
(+ 1 2 3 4 5) ;=> 15
(* 2 3 4) ;=> 24
(< 1 2 3 4) ;=> true (chained comparison)
Python integers are already arbitrary-precision, and Basilisp inherits that:
(* (range 1 100)) ;; not quite — but you get the idea
(reduce * (range 1N 21N)) ;=> 2432902008176640000
Strings
Python:
word = "Python"
word[0] # 'P'
word[0:2] # 'Py'
len(word) # 6
f"Hello, {word}!"
Basilisp:
(def word "Basilisp")
(get word 0) ;=> "B"
(subs word 0 2) ;=> "Ba"
(count word) ;=> 8
(str "Hello, " word "!") ;=> "Hello, Basilisp!"
Strings in Basilisp are native Python strings. str concatenates, subs slices, and count gives the length. For formatted output, use Clojure-style format:
(format "Hello, %s! You have %d messages." word 42)
;=> "Hello, Basilisp! You have 42 messages."
Lists and Vectors
Python:
squares = [1, 4, 9, 16, 25]
squares[0] # 1
squares + [36, 49] # [1, 4, 9, 16, 25, 36, 49]
squares.append(36) # mutates!
Basilisp:
(def squares [1 4 9 16 25])
(get squares 0) ;=> 1
(squares 0) ;=> 1 (vectors are functions!)
(conj squares 36) ;=> [1 4 9 16 25 36]
(into squares [36 49]) ;=> [1 4 9 16 25 36 49]
squares ;=> [1 4 9 16 25] (unchanged!)
This is the fundamental shift. Basilisp vectors are persistent and immutable. conj doesn't modify squares — it returns a new vector that shares structure with the old one. No defensive copying, no "who mutated my list" bugs, no thread-safety concerns.
Vectors can also act as functions of their index — (squares 0) is equivalent to (get squares 0).
4. Control Flow
if / cond
Python:
if x < 0:
print("Negative")
elif x == 0:
print("Zero")
else:
print("Positive")
Basilisp:
(cond
(neg? x) (println "Negative")
(zero? x) (println "Zero")
:else (println "Positive"))
if is a simple two-branch expression:
(def label (if (neg? x) "negative" "non-negative"))
No ternary operator needed — if already returns a value. Everything in Basilisp is an expression.
for loops
Python:
for word in ["cat", "window", "defenestrate"]:
print(word, len(word))
Basilisp:
(doseq [word ["cat" "window" "defenestrate"]]
(println word (count word)))
doseq is for side effects (like printing). When you want to transform data — which is most of the time — use map, filter, or for:
(for [word ["cat" "window" "defenestrate"]]
[word (count word)])
;=> (["cat" 3] ["window" 6] ["defenestrate" 13])
Basilisp's for is a list comprehension, not a loop. It returns a lazy sequence.
range
Python:
list(range(0, 10, 2)) # [0, 2, 4, 6, 8]
Basilisp:
(range 0 10 2) ;=> (0 2 4 6 8)
Basilisp's range is lazy — it generates values on demand without allocating memory for all of them.
while / loop-recur
Python:
a, b = 0, 1
while a < 10:
print(a)
a, b = b, a + b
Basilisp:
(loop [a 0 b 1]
(when (< a 10)
(println a)
(recur b (+ a b))))
loop/recur is Basilisp's structured iteration. recur jumps back to the loop head with new bindings — it's tail-call optimized with zero stack growth. No while True needed.
Pattern Matching
Python 3.10+:
match command:
case "quit":
quit_game()
case "help":
show_help()
case _:
print("Unknown")
Basilisp:
(case command
"quit" (quit-game)
"help" (show-help)
(println "Unknown"))
case handles constant dispatch. For more complex pattern matching, cond with predicates is idiomatic:
(cond
(string? x) (println "It's a string")
(vector? x) (println "It's a vector")
:else (println "Something else"))
Functions
Python:
def fib(n):
"""Return Fibonacci series up to n."""
result = []
a, b = 0, 1
while a < n:
result.append(a)
a, b = b, a + b
return result
Basilisp:
(defn fib
"Return Fibonacci series up to n."
[n]
(->> [0 1]
(iterate (fn [[a b]] [b (+ a b)]))
(map first)
(take-while #(< % n))))
The Basilisp version reads as a pipeline: start with [0 1], generate successive pairs, extract the first element, and keep going while less than n. No mutation, no accumulator, no while loop. Data flows through transformations.
The ->> "thread-last" macro turns nested calls into a readable pipeline — one of Clojure's most beloved features.
Default Arguments and Keyword Arguments
Python:
def greet(name, greeting="Hello"):
print(f"{greeting}, {name}!")
def log(*args, **kwargs):
print(args, kwargs)
Basilisp:
(defn greet
([name] (greet name "Hello"))
([name greeting] (println (str greeting ", " name "!"))))
(defn log [& args]
(println args))
Basilisp uses multi-arity functions for defaults: calling (greet "Ada") dispatches to the one-arg version, which calls the two-arg version with "Hello".
For keyword arguments, Basilisp supports map destructuring:
(defn create-user [& {:keys [name email role] :or {role "member"}}]
{:name name :email email :role role})
(create-user :name "Ada" :email "ada@example.com")
;=> {:name "Ada" :email "ada@example.com" :role "member"}
Lambda Expressions
Python:
double = lambda x: x * 2
sorted(pairs, key=lambda p: p[1])
Basilisp:
(def double #(* % 2)) ; reader macro shorthand
(sort-by second pairs) ; or: (sort-by #(nth % 1) pairs)
#(* % 2) is shorthand for (fn [x] (* x 2)). % is the argument, %2 the second argument, etc. No restriction to a single expression — fn bodies can be as complex as needed.
5. Data Structures
The Full Collection Family
| Python | Basilisp | Properties |
[1, 2, 3] | [1 2 3] | Vector: immutable, indexed, O(log32 n) access |
(1, 2, 3) | N/A (vectors cover this) | Vectors are already immutable |
{"a": 1} | {"a" 1} or {:a 1} | Map: immutable, O(log n) lookup |
{1, 2, 3} | #{1 2 3} | Set: immutable, O(1) membership |
| N/A | '(1 2 3) | List: immutable linked list |
Every collection is immutable and persistent. "Persistent" means new versions share structure with old ones — adding to a million-element vector doesn't copy a million elements.
Need a mutable Python list for interop? Use the #py prefix:
(def py-list #py [1 2 3]) ; native Python list
(def py-dict #py {"a" 1}) ; native Python dict
(def py-set #py #{1 2 3}) ; native Python set
Maps (Dictionaries)
Python:
tel = {"jack": 4098, "sape": 4139}
tel["guido"] = 4127
del tel["sape"]
{x: x**2 for x in range(5)}
Basilisp:
(def tel {:jack 4098 :sape 4139})
(assoc tel :guido 4127) ;=> {:jack 4098 :sape 4139 :guido 4127}
(dissoc tel :sape) ;=> {:jack 4098}
(into {} (map (fn [x] [x (* x x)]) (range 5)))
Keywords (:jack, :sape) are idiomatic Basilisp map keys. They double as accessor functions:
(:jack tel) ;=> 4098
(:missing tel 0) ;=> 0 (default value)
assoc and dissoc return new maps. The original is never modified.
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}
Basilisp:
(require '[basilisp.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}
Sets are also functions — (a 3) returns 3 (truthy), (a 7) returns nil (falsy). Handy for filtering:
(filter #{3 4 5} (range 10)) ;=> (3 4 5)
List Comprehensions
Python:
[x**2 for x in range(10) if x % 2 == 0]
Basilisp:
(for [x (range 10) :when (even? x)]
(* x x))
;=> (0 4 16 36 64)
for supports :when for filtering, :let for local bindings, and multiple binding forms for nested iteration — all lazily evaluated.
Destructuring
Basilisp's destructuring is more powerful than Python's unpacking:
;; Sequential
(let [[a b c & rest] [1 2 3 4 5 6]]
[a b c rest])
;=> [1 2 3 (4 5 6)]
;; Associative
(let [{:keys [name email] :or {email "N/A"}} {:name "Ada"}]
[name email])
;=> ["Ada" "N/A"]
;; Nested
(let [[a [b c]] [1 [2 3]]]
[a b c])
;=> [1 2 3]
This works everywhere — let, fn parameters, for bindings, loop bindings.
Looping Techniques
Python:
for i, v in enumerate(["tic", "tac", "toe"]):
print(i, v)
for q, a in zip(questions, answers):
print(q, a)
for key, value in knights.items():
print(key, value)
Basilisp:
(doseq [[i v] (map-indexed vector ["tic" "tac" "toe"])]
(println i v))
(doseq [[q a] (map vector questions answers)]
(println q a))
(doseq [[key value] knights] ; maps are sequences of [k v] pairs
(println key value))
6. Modules and Namespaces
Importing Python
Python:
import math
from os.path import join, exists
import numpy as np
Basilisp:
(import math)
(import [os.path :as path])
(import [numpy :as np])
Or in the ns declaration (the idiomatic way):
(ns myapp.core
(:import math
[os.path :as path]
[numpy :as np]))
Requiring Basilisp Namespaces
(ns myapp.core
(:require [basilisp.string :as str]
[basilisp.set :as set]
[myapp.utils :refer [helper-fn]]))
This mirrors Clojure's ns macro exactly. Basilisp files use the .lpy extension and map to namespaces by their directory path.
Python Builtins
All Python builtins are available under the python namespace without imports:
(python/abs -42) ;=> 42
(python/len #py [1 2 3]) ;=> 3
(python/type 42) ;=> <class 'int'>
The Script Entry Point
Python:
if __name__ == "__main__":
main()
Basilisp scripts can use a shebang:
#!/usr/bin/env basilisp run
(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)
Basilisp:
(def name "World")
(str "Hello, " name "!") ;=> "Hello, World!"
(format "Hello, %s!" name) ;=> "Hello, World!"
(format "%.2f" 3.14159) ;=> "3.14"
str concatenates, format uses printf-style directives. For complex output, basilisp.pprint/pprint pretty-prints data structures.
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")
Basilisp:
(def content (slurp "data.txt"))
(spit "output.txt" "Hello\n")
slurp and spit — inherited from Clojure, arguably the best-named I/O functions in any language. For line-by-line processing:
(with-open [rdr (basilisp.io/reader "data.txt")]
(doseq [line (line-seq rdr)]
(println line)))
with-open mirrors Python's with statement — it ensures the resource is closed when the block exits.
JSON
Python:
import json
data = json.loads('{"name": "Ada"}')
json.dumps(data)
Basilisp:
(require '[basilisp.json :as json])
(def data (json/read-str "{\"name\": \"Ada\"}" ** :key-fn keyword))
;=> {:name "Ada"}
(json/write-str data)
;=> "{\"name\":\"Ada\"}"
The :key-fn keyword option keywordizes string keys — JSON becomes idiomatic Basilisp data instantly.
8. Errors and Exceptions
Try / Catch
Python:
try:
x = int(input("Number: "))
except ValueError as e:
print(f"Invalid: {e}")
else:
print(f"Got: {x}")
finally:
print("Done")
Basilisp:
(try
(let [x (python/int (python/input "Number: "))]
(println "Got:" x))
(catch python/ValueError e
(println "Invalid:" (str e)))
(finally
(println "Done")))
Since Basilisp runs on Python, it catches Python exceptions directly. python/ValueError, python/TypeError, python/IOError — they're all available.
Raising Exceptions
Python:
raise ValueError("something went wrong")
raise RuntimeError("failed") from original_error
Basilisp:
(throw (python/ValueError "something went wrong"))
(throw (python/RuntimeError "failed") original-error)
throw accepts an optional second argument for exception chaining, matching Python's raise ... from ....
Custom Exceptions
Python:
class InsufficientFunds(Exception):
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(f"Need {amount}, have {balance}")
Basilisp — you can define a Python class, or use data-oriented error handling with ex-info:
(throw (ex-info "Insufficient funds"
{:type :insufficient-funds
:balance 100
:amount 150}))
(try
(do-transaction)
(catch python/Exception e
(when-let [data (ex-data e)]
(println "Balance:" (:balance data))
(println "Needed:" (:amount data)))))
ex-info creates an exception carrying a data map — Clojure's "data over classes" philosophy. One generic mechanism, infinite data shapes.
9. Classes and Objects
Python's Object-Oriented Approach
class Dog:
kind = "canine"
def __init__(self, name):
self.name = name
def speak(self):
return f"{self.name} says woof!"
rex = Dog("Rex")
rex.speak()
Basilisp's Data-Oriented Approach
The simplest equivalent — just a map and a function:
(def rex {:kind "canine" :name "Rex"})
(defn speak [dog]
(str (:name dog) " says woof!"))
(speak rex) ;=> "Rex says woof!"
Protocols (Interfaces)
For polymorphism without inheritance, use protocols:
(defprotocol Speakable
(speak [this]))
(defrecord Dog [name]
Speakable
(speak [this] (str name " says woof!")))
(defrecord Cat [name]
Speakable
(speak [this] (str name " says meow!")))
(speak (->Dog "Rex")) ;=> "Rex says woof!"
(speak (->Cat "Whiskers")) ;=> "Whiskers says meow!"
Records implement the map interface, so :name still works as an accessor:
(def rex (->Dog "Rex"))
(:name rex) ;=> "Rex"
(assoc rex :name "Spot") ;=> #user.Dog{:name "Spot"}
Multimethods
For dispatch on any function of the arguments (not just type):
(defmulti encounter (fn [a b] [(:species a) (:species b)]))
(defmethod encounter [:dog :cat] [a b]
(str (:name a) " chases " (:name b)))
(defmethod encounter [:cat :dog] [a b]
(str (:name a) " hisses at " (:name b)))
(defmethod encounter :default [a b]
(str (:name a) " ignores " (:name b)))
(encounter {:species :dog :name "Rex"}
{:species :cat :name "Whiskers"})
;=> "Rex chases Whiskers"
Python Interop for Real Classes
Need to extend a Python class? Use deftype or proxy:
(import [http.server :refer [BaseHTTPRequestHandler]])
(deftype MyHandler [request client-address server]
^:abstract BaseHTTPRequestHandler
(do-GET [self]
(.send-response self 200)
(.end-headers self)
(.write (.-wfile self) (.encode "Hello from Basilisp!"))))
Iterators and Generators
Python:
def countdown(n):
while n > 0:
yield n
n -= 1
Basilisp:
;; Lazy sequence (idiomatic)
(defn countdown [n]
(when (pos? n)
(lazy-seq (cons n (countdown (dec n))))))
(countdown 5) ;=> (5 4 3 2 1)
Or using Python-style generators via yield:
(defn countdown [n]
(loop [i n]
(when (pos? i)
(yield i)
(recur (dec i)))))
Most of the time, lazy sequences are more idiomatic than generators in Basilisp.
Generator Expressions / Transducers
Python:
sum(x*x for x in range(10))
Basilisp:
;; Simple
(reduce + (map #(* % %) (range 10)))
;; With threading macro
(->> (range 10) (map #(* % %)) (reduce +))
;; With transducers (most efficient)
(transduce (map #(* % %)) + (range 10))
Transducers are Basilisp's power tool for data pipelines. They compose transformations without creating intermediate sequences:
(def xform
(comp
(filter even?)
(map #(* % %))
(take 5)))
(into [] xform (range 100))
;=> [0 4 16 36 64]
10. The Standard Library
Python's Ecosystem, Basilisp's Abstractions
Basilisp has two standard libraries: its own Clojure-ported core, and all of Python's.
Basilisp core libraries:
| Library | Purpose |
basilisp.string | String manipulation (split, join, trim, replace...) |
basilisp.set | Set operations (union, intersection, difference...) |
basilisp.io | I/O (reader, writer, resource...) |
basilisp.json | JSON read/write |
basilisp.edn | EDN (Extensible Data Notation) read/write |
basilisp.pprint | Pretty-printing |
basilisp.shell | Shell command execution |
basilisp.test | Testing framework |
basilisp.walk | Recursive data structure walking |
basilisp.stacktrace | Stack trace formatting |
Plus every Python module:
;; OS and files
(import os)
(os/listdir ".")
;; Regex (Basilisp has built-in support too)
(re-find #"\d+" "hello 42 world") ;=> "42"
(re-seq #"\d+" "hello 42 world 99") ;=> ("42" "99")
;; Math
(import math)
(math/cos (/ math/pi 4))
;; Dates
(import [datetime :refer [date]])
(def now (date/today))
(.strftime now "%Y-%m-%d")
;; HTTP
(import [urllib.request :as req])
(slurp (req/urlopen "https://httpbin.org/get"))
Third-Party Libraries — The Killer Feature
;; NumPy
(import [numpy :as np])
(def arr (np/array [1 2 3 4 5]))
(np/mean arr) ;=> 3.0
;; Requests
(import requests)
(def r (requests/get "https://api.github.com"))
(.-status-code r) ;=> 200
;; Flask
(import [flask :refer [Flask]])
(def app (Flask __name__))
If it's on PyPI, it works in Basilisp. pip install it and import it. This is the reason Basilisp exists — Clojure semantics with Python's ecosystem.
11. Advanced Standard Library
Output Formatting
(require '[basilisp.pprint :refer [pprint]])
(pprint {:name "Ada"
:languages ["Python" "Basilisp" "Clojure"]
:scores {:math 98 :cs 100}})
Concurrency: Atoms
Where Python requires locks for shared state, Basilisp uses atoms:
(def counter (atom 0))
(swap! counter inc) ;=> 1
(swap! counter + 10) ;=> 11
@counter ;=> 11 (deref)
(reset! counter 0) ;=> 0
swap! is atomic — it retries if another thread modified the atom between read and write. No locks, no deadlocks, no race conditions. The update function must be pure (free of side effects).
Add validators and watchers:
(set-validator! counter #(>= % 0)) ; must be non-negative
(add-watch counter :log
(fn [_ _ old new]
(println "Changed from" old "to" new)))
Futures and Parallel Map
(def f (future (expensive-computation)))
@f ;=> result (blocks until ready)
(pmap expensive-fn large-collection) ; parallel map
Transducers (Continued)
;; Process a CSV: extract prices, filter positives, sum them
(def xform
(comp
(map :price)
(keep identity)
(filter pos?)))
(transduce xform + 0 data)
Transducers are reusable, composable, and work with any data source — sequences, channels, or custom reducers.
Exact Arithmetic
(+ 1/10 2/10) ;=> 3/10 (exact Fraction)
(= (+ 1/10 2/10) 3/10) ;=> true
(* 0.1M 0.2M) ;=> 0.02M (Decimal)
M suffix creates decimal.Decimal values, ratios use / syntax for fractions.Fraction.
12. Virtual Environments and Packages
Basilisp uses Python's packaging ecosystem directly:
python -m venv myenv
source myenv/bin/activate
pip install basilisp
pip install requests flask numpy
Project structure:
myproject/
src/myproject/core.lpy
tests/myproject/test_core.lpy
pyproject.toml
Basilisp files use .lpy and live alongside Python files. A project can freely mix .py and .lpy — Python calls Basilisp, Basilisp calls Python.
For bootstrapping Basilisp from Python:
# __init__.py
import basilisp.main
basilisp.main.init()
After this, import myproject.core will find and compile .lpy files automatically.
13. Floating-Point Arithmetic
Same hardware, same IEEE 754, same surprise:
(= (+ 0.1 0.2) 0.3) ;=> false
But Basilisp offers two escape hatches:
;; Exact rationals (Fraction)
(= (+ 1/10 2/10) 3/10) ;=> true
;; Exact decimals
(= (+ 0.1M 0.2M) 0.3M) ;=> true
Both are literal syntax — no imports, no constructors. Just 1/10 or 0.1M.
14. The REPL Experience
The Basilisp REPL supports:
- History and line editing
- Syntax highlighting (with
pip install basilisp[pygments]) - nREPL server for editor integration (Emacs CIDER, VS Code Calva, etc.)
- The
*1,*2,*3special vars hold the last three results *eholds the last exception
basilisp.user=> (+ 1 2)
3
basilisp.user=> (* *1 10)
30
Editor integration via nREPL means you can evaluate expressions inline, inspect data, and navigate documentation without leaving your editor.
15. The Secret Weapon: Macros
Python doesn't have macros. Basilisp does, and they're hygienic.
Example: A Timing Macro
(defmacro time-it [& body]
`(let [start# (python/time.time)]
~@body
(println "Elapsed:" (- (python/time.time) start#) "seconds")))
(time-it
(reduce + (range 1000000)))
The # suffix generates unique symbols to prevent variable capture. The backtick ` quotes the template, ~ unquotes values, and ~@ splices sequences.
Example: A Retry Macro
(defmacro with-retry [n & body]
`(loop [attempts# ~n]
(let [result# (try
{:ok (do ~@body)}
(catch python/Exception e#
(if (pos? attempts#)
{:retry true}
(throw e#))))]
(if (:retry result#)
(recur (dec attempts#))
(:ok result#)))))
(with-retry 3
(fetch-flaky-api))
In Python, you'd write a decorator or a wrapper function with callbacks. In Basilisp, the macro generates exactly the code you need, with zero runtime overhead. The caller's code looks like a native language feature.
Example: Thread-safe Logging
(defmacro log [level & args]
`(when (>= ~level @log-level)
(locking log-lock
(println (str "[" ~level "]") ~@args))))
Macros let you build domain-specific languages embedded directly in Basilisp. This is the deep reason Lisp has survived for 65+ years.
The Big Picture
| Dimension | Python | Basilisp |
| Runtime | CPython bytecode | CPython bytecode (same!) |
| Syntax | Indentation, keywords | S-expressions, minimal |
| Paradigm | Multi-paradigm (imperative-first) | Functional-first (with escape hatches) |
| Mutability | Mutable by default | Immutable by default |
| Data Structures | Lists, dicts, sets (mutable) | Vectors, maps, sets (persistent, immutable) |
| Concurrency | GIL, locks, async/await | Atoms, futures, pmap |
| Polymorphism | Classes + inheritance | Protocols + multimethods |
| Metaprogramming | Decorators, metaclasses | Macros (compile-time code generation) |
| Libraries | PyPI | PyPI (same!) + Basilisp core |
| Type System | Dynamic, gradual typing | Dynamic |
| Sequences | Iterators, generators | Lazy seqs, transducers |
| REPL | Basic | nREPL, editor integration |
Closing Thoughts
Basilisp occupies a remarkable niche. It brings Clojure's most powerful ideas — immutable data, sequence abstractions, protocols, atoms, macros — to the Python ecosystem. Every PyPI library works. Every Python tool applies. But the code you write is fundamentally different: data that can't change, functions that compose, and a language that reshapes itself to fit your problem.
If you've ever wished Python's data structures were immutable. If you've ever written a pipeline of map/filter/reduce and thought "this should be more natural." If you've ever fought a race condition that an atom would have prevented. Basilisp might be the language that makes you rethink how you write Python.
You don't have to migrate. Start with one .lpy file in your existing Python project. Import your Python code. See how it feels. The parentheses will be strange for an hour. The immutability will be strange for a day. Then you'll wonder how you ever lived without them.
(println "Welcome to Basilisp.")
(println "Where Python meets Clojure,")
(println "and data stays exactly as you left it.")
Get started: pip install basilisp && basilisp repl.
This article was written as a companion to the official Python tutorial. Every section maps to a chapter in that tutorial, showing the same concepts through Basilisp's Clojure-on-Python lens. For more, visit docs.basilisp.org.