Skip to main content

Command Palette

Search for a command to run...

Python to Hy: What If Python Wore a Lisp Costume?

Updated
17 min read

title: "Python to Hy: What If Python Wore a Lisp Costume?" published: true description: "A comprehensive comparison of Python and Hy — the Lisp that compiles to Python. Walking through every topic in the official Python tutorial, showing how the same language looks in a radically different syntax." tags: python, lisp, functional, beginners

cover_image:

Python to Hy: What If Python Wore a Lisp Costume?

What if you could have Python's entire ecosystem — every library, every framework, every tool — but write your code in Lisp? That's Hy. Not a new language with a Python bridge. Not a transpiler with gaps. Hy is Python, wearing parentheses.

Hy compiles directly to Python's AST. Your Hy code becomes the same bytecode that CPython runs. You can import Python from Hy, import Hy from Python, and mix them freely in the same project. The difference is pure syntax — and the superpowers that Lisp syntax unlocks.

This article walks through every major topic in the official Python tutorial and shows the same concept in Hy. If you know Python, you already know 90% of Hy. The other 10% might change how you think about code.


1. Whetting Your Appetite

Python sells itself on readability, rapid prototyping, and batteries included. It eliminates the compile-link cycle and encourages experimentation in the REPL.

Hy inherits all of that — literally. Since Hy compiles to Python bytecode, every selling point of Python is a selling point of Hy. But Hy adds three things Python can't easily offer:

  1. Macros — functions that write code at compile time
  2. Homoiconicity — code is data, data is code
  3. Expression everything — no statements, everything returns a value

Why would you want these? Because they let you mold the language to your problem, rather than molding your problem to the language.


2. The Interpreter / The REPL

Python:

>>> 2 + 2
4
>>> print("Hello, world!")
Hello, world!

Hy:

=> (+ 2 2)
4
=> (print "Hello, world!")
Hello, world!

Install Hy with pip install hy, then type hy to enter the REPL. It feels like Python's interactive interpreter, but with prefix notation: the operator comes first, then its arguments, all wrapped in parentheses. Run scripts with hy myfile.hy.

The parentheses are the point. They make every expression unambiguous — no operator precedence to memorize, no parsing surprises. (- (* (+ 1 3 88) 2) 8) is clearer than (1 + 3 + 88) * 2 - 8 once you've spent an afternoon with it.


3. An Informal Introduction

Numbers

Python:

>>> 17 / 3       # 5.666...
>>> 17 // 3      # 5
>>> 17 % 3       # 2
>>> 5 ** 2       # 25

Hy:

=> (/ 17 3)      ; 5.666...
=> (// 17 3)     ; 5
=> (% 17 3)      ; 2
=> (** 5 2)      ; 25

Same operators, same results, different notation. Hy uses Python's exact numeric types — int, float, complex, Decimal — because it is Python underneath.

One bonus: Hy operators accept multiple arguments.

=> (+ 1 2 3 4 5)     ; 15
=> (* 2 3 4)          ; 24
=> (< 1 2 3 4)        ; True (chained comparison!)

That last one is equivalent to Python's 1 < 2 < 3 < 4 — but in Hy, it naturally generalizes.

Strings

Python:

word = "Python"
word[0]        # 'P'
word[0:2]      # 'Py'
len(word)      # 6
f"Hello, {word}!"

Hy:

(setv word "Hy")
(get word 0)          ; "H"
(cut word 0 2)        ; "Hy"
(len word)            ; 2
(+ "Hello, " word "!")  ; or use f-strings:
f"Hello, {word}!"     ; yes, Hy supports f-strings!

Hy supports all Python string features: f-strings, raw strings (r"..."), byte strings (b"..."), and triple-quoted strings. They're the same objects.

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 the list

Hy:

(setv squares [1 4 9 16 25])
(get squares 0)              ; 1
(+ squares [36 49])          ; [1 4 9 16 25 36 49]
(.append squares 36)         ; mutates the list — same as Python

Notice: commas are whitespace in Hy. [1 2 3] and [1, 2, 3] are both valid. Lists are Python lists — mutable, familiar, identical behavior.


4. Control Flow

if / cond

Python:

if x < 0:
    print("Negative")
elif x == 0:
    print("Zero")
else:
    print("Positive")

Hy:

(cond
  (< x 0)  (print "Negative")
  (= x 0)  (print "Zero")
  True      (print "Positive"))

In Hy, if takes exactly three forms — condition, then, else:

(if (< x 0)
  (print "Negative")
  (print "Non-negative"))

And because if is an expression, you can use it anywhere:

(setv label (if (< x 0) "negative" "non-negative"))

Python needs a ternary label = "negative" if x < 0 else "non-negative" for this. In Hy, if already does it — no special syntax needed.

Need multiple statements in a branch? Use do:

(if (= 1 1)
  (do
    (print "Math works.")
    (print "The universe is safe."))
  (do
    (print "Math has failed.")
    (print "The universe is doomed.")))

for loops

Python:

for word in ["cat", "window", "defenestrate"]:
    print(word, len(word))

Hy:

(for [word ["cat" "window" "defenestrate"]]
  (print word (len word)))

The binding goes inside square brackets — [word iterable] — then the body follows.

range

Python:

list(range(0, 10, 2))  # [0, 2, 4, 6, 8]

Hy:

(list (range 0 10 2))  ; [0, 2, 4, 6, 8]

It's the same range. Because it is the same range.

while

Python:

a, b = 0, 1
while a < 10:
    print(a)
    a, b = b, a + b

Hy:

(setv a 0  b 1)
(while (< a 10)
  (print a)
  (setv [a b] [b (+ a b)]))

Tuple unpacking works via (setv [a b] [b (+ a b)]) — Hy mirrors Python's destructuring.

break and continue

Python:

for n in range(2, 10):
    if n % 2 == 0:
        continue
    print(n)

Hy:

(for [n (range 2 10)]
  (when (= (% n 2) 0) (continue))
  (print n))

when is a handy shorthand for (if condition (do ...) None).

Pattern Matching

Python 3.10+:

match command:
    case "quit":
        quit_game()
    case "go" + direction:
        go(direction)
    case _:
        print("Unknown command")

Hy:

(match command
  "quit"              (quit-game)
  (+ "go" direction)  (go direction)
  _                   (print "Unknown command"))

Same structural pattern matching, same Python 3.10+ requirement, different syntax.

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

Hy:

(defn fib [n]
  "Return Fibonacci series up to n."
  (setv result []  a 0  b 1)
  (while (< a n)
    (.append result a)
    (setv [a b] [b (+ a b)]))
  result)

Functions are defined with defn. The docstring goes right after the parameter vector. The last expression is the return value — no explicit return needed (though return exists for early exits).

Default Arguments, args, *kwargs

Python:

def greet(name, greeting="Hello"):
    print(f"{greeting}, {name}!")

def log(*args, **kwargs):
    print(args, kwargs)

Hy:

(defn greet [name [greeting "Hello"]]
  (print f"{greeting}, {name}!"))

(defn log [#* args #** kwargs]
  (print args kwargs))

Default values are expressed as [param default] nested inside the parameter list. #* captures varargs, #** captures keyword args. The / and * separators for positional-only and keyword-only parameters work too:

(defn f [a / b [c 3] * d e #** kwargs]
  [a b c d e kwargs])

Lambda Expressions

Python:

double = lambda x: x * 2
sorted(pairs, key=lambda p: p[1])

Hy:

(setv double (fn [x] (* x 2)))
(sorted pairs :key (fn [p] (get p 1)))

Hy uses fn instead of lambda. Unlike Python's lambda, which is restricted to a single expression, Hy's fn can contain multiple forms in the body. No arbitrary limitation.


5. Data Structures

Every Python Type, Natively

PythonHyNotes
[1, 2, 3][1 2 3]List
(1, 2, 3)#(1 2 3)Tuple
{1, 2, 3}#{1 2 3}Set
{"a": 1, "b": 2}{"a" 1 "b" 2}Dict
True / FalseTrue / FalseBool
NoneNoneNoneType

The syntax is compact. Commas are optional whitespace. Dict literals alternate keys and values without the colon.

List Comprehensions

Python:

[x**2 for x in range(10) if x % 2 == 0]

Hy:

(lfor x (range 10) :if (= (% x 2) 0) (** x 2))

Hy has a full family of comprehensions:

(lfor x (range 5) (* x 2))            ; list: [0, 2, 4, 6, 8]
(sfor x (range 5) (* x 2))            ; set: #{0, 2, 4, 6, 8}
(dfor x (range 5) x (* x 10))         ; dict: {0: 0, 1: 10, 2: 20, ...}
(gfor x (range 5) (* x 2))            ; generator (lazy)

Each one supports :if for filtering and multiple binding clauses for nested iteration — just like Python's comprehensions but with a uniform prefix syntax.

Stacks and Queues

Python:

stack = [3, 4, 5]
stack.append(6)
stack.pop()          # 6

from collections import deque
queue = deque(["Eric", "John"])
queue.append("Terry")
queue.popleft()      # 'Eric'

Hy:

(setv stack [3 4 5])
(.append stack 6)
(.pop stack)          ; 6

(import collections [deque])
(setv queue (deque ["Eric" "John"]))
(.append queue "Terry")
(.popleft queue)      ; "Eric"

Method calls in Hy use the .method syntax: (.append stack 6) is stack.append(6). You can also write (stack.append 6) — both work.

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)

Hy:

(for [[i v] (enumerate ["tic" "tac" "toe"])]
  (print i v))

(for [[q a] (zip questions answers)]
  (print q a))

(for [[key value] (.items knights)]
  (print key value))

Destructuring in the for binding is natural — just wrap the variables in brackets.


6. Modules and Namespaces

Importing

Python:

import math
from os.path import join, exists
import numpy as np

Hy:

(import math)
(import os.path [join exists])
(import numpy :as np)

Same semantics, slightly different syntax. You can combine multiple imports too:

(import sys
        os.path [exists isdir]
        json)

The Quick Import Shortcut

Need something fast without a formal import? Hy has a trick:

(print (hy.I.math.sqrt 2))       ; 1.4142135623730951
(print (hy.I.os.path.exists "/tmp"))  ; True

hy.I lets you use dotted module paths inline — great for one-off uses in the REPL.

The __name__ == "__main__" Pattern

Python:

if __name__ == "__main__":
    main()

Hy:

(when (= __name__ "__main__")
  (main))

Works exactly the same way. Since Hy modules are Python modules, __name__ behaves identically.

Packages

Hy files use the .hy extension and can be organized into packages just like Python. The key requirement: put import hy at the top of your __init__.py so Python's import machinery knows how to handle .hy files.


7. Input and Output

String Formatting

Python:

name = "World"
f"Hello, {name}!"
"{:.2f}".format(3.14159)
"The answer is %d" % 42

Hy:

(setv name "World")
f"Hello, {name}!"
(.format "{:.2f}" 3.14159)
(% "The answer is %d" 42)

F-strings work natively in Hy. The .format method is called with the dot-method syntax. Even % formatting works — it's just the modulo operator applied to a string.

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")

Hy:

(with [f (open "data.txt" :encoding "utf-8")]
  (setv content (.read f)))

(with [f (open "output.txt" "w" :encoding "utf-8")]
  (.write f "Hello\n"))

with in Hy mirrors Python's context manager protocol exactly. The binding [f (open ...)] binds the context manager's result to f.

But here's something Hy can do that Python can't — with is an expression:

(setv content (with [f (open "data.txt")] (.read f)))

In Python, with is a statement. You can't assign its result directly. In Hy, everything is an expression.

JSON

Python:

import json
data = json.loads('{"name": "Ada"}')
json.dumps(data)

Hy:

(import json)
(setv data (json.loads "{\"name\": \"Ada\"}"))
(json.dumps data)

Same library, same functions, different syntax.


8. Errors and Exceptions

Try / Except

Python:

try:
    x = int(input("Number: "))
except ValueError as e:
    print(f"Invalid: {e}")
else:
    print(f"Got: {x}")
finally:
    print("Done")

Hy:

(try
  (setv x (int (input "Number: ")))
  (except [e ValueError]
    (print f"Invalid: {e}"))
  (else
    (print f"Got: {x}"))
  (finally
    (print "Done")))

All four clauses — try, except, else, finally — map directly. Multiple exception types work too:

(except [[RuntimeError TypeError NameError]]
  (print "One of those happened"))

Raising Exceptions

Python:

raise ValueError("something went wrong")
raise RuntimeError("failed") from original_error

Hy:

(raise (ValueError "something went wrong"))
(raise (RuntimeError "failed") :from original-error)

Custom Exceptions

Python:

class InsufficientFunds(Exception):
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        super().__init__(f"Need {amount}, have {balance}")

Hy:

(defclass InsufficientFunds [Exception]
  (defn __init__ [self balance amount]
    (setv self.balance balance)
    (setv self.amount amount)
    (.__init__ (super) f"Need {amount}, have {balance}")))

9. Classes

Python and Hy handle classes identically in terms of capabilities — because Hy classes are Python classes. The difference is purely syntactic.

Basic Class Definition

Python:

class Dog:
    kind = "canine"

    def __init__(self, name):
        self.name = name

    def speak(self):
        return f"{self.name} says woof!"

Hy:

(defclass Dog []
  (setv kind "canine")

  (defn __init__ [self name]
    (setv self.name name))

  (defn speak [self]
    f"{self.name} says woof!"))

Inheritance

Python:

class Puppy(Dog):
    def speak(self):
        return f"{self.name} says yip!"

Hy:

(defclass Puppy [Dog]
  (defn speak [self]
    f"{self.name} says yip!"))

Multiple inheritance, super(), class methods, static methods, properties — they all work:

(defclass MyClass [Base1 Base2]
  (defn [staticmethod] helper [x]
    (* x 2))

  (defn [classmethod] create [cls name]
    (cls name))

  (defn [property] label [self]
    f"I am {self.name}"))

Decorators go in brackets before the function name — (defn [staticmethod] ...) is Hy's equivalent of @staticmethod.

Iterators

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]

Hy:

(defclass Reverse []
  (defn __init__ [self data]
    (setv self.data data)
    (setv self.index (len data)))

  (defn __iter__ [self]
    self)

  (defn __next__ [self]
    (when (= self.index 0)
      (raise StopIteration))
    (setv self.index (- self.index 1))
    (get self.data self.index)))

Generators

Python:

def reverse(data):
    for index in range(len(data) - 1, -1, -1):
        yield data[index]

Hy:

(defn reverse-it [data]
  (for [index (range (- (len data) 1) -1 -1)]
    (yield (get data index))))

Generator expressions also work via gfor:

(sum (gfor x (range 10) (* x x)))  ; 285

10. The Standard Library

Since Hy is Python, the entire standard library is already yours. No wrappers, no bindings, no FFI. Just import.

Python:

import os
import re
import math
import datetime
import json
import unittest

Hy:

(import os)
(import re)
(import math)
(import datetime)
(import json)
(import unittest)

These aren't Hy equivalents of the Python modules. They are the Python modules. (math.sqrt 2) calls exactly the same C code as math.sqrt(2).

Some examples across the standard library:

;; OS and files
(import os)
(os.listdir ".")
(os.path.exists "/tmp")

;; Regex
(import re)
(re.findall r"\d+" "hello 42 world 99")  ; ["42" "99"]

;; Math
(import math)
(math.cos (/ math.pi 4))

;; Dates
(import datetime [date])
(setv now (date.today))
(.strftime now "%Y-%m-%d")

;; Performance
(import timeit)
(.timeit (timeit.Timer "(** 2 100)" "None") :number 10000)

Third-Party Libraries Too

Python:

import requests
r = requests.get("https://api.github.com")
print(r.status_code)

Hy:

(import requests)
(setv r (requests.get "https://api.github.com"))
(print r.status_code)

NumPy, Flask, Django, pandas, PyTorch — if it's on PyPI, it works in Hy. No translation layer. This is Hy's killer feature and the reason it exists.


11. Advanced Standard Library

Output Formatting

(import pprint [pprint])
(pprint {"name" "Ada" "languages" ["Python" "Hy" "Haskell"]})

(import textwrap)
(print (textwrap.fill "A very long paragraph..." :width 40))

Templating

(import string [Template])
(setv t (Template "$who likes $what"))
(.substitute t :who "Ada" :what "Hy")  ; "Ada likes Hy"

Multi-threading

(import threading [Thread])
(setv t (Thread :target (fn [] (print "Hello from thread!"))))
(.start t)
(.join t)

Logging

(import logging)
(logging.basicConfig :level logging.INFO)
(logging.info "Application started")
(logging.warning "Watch out!")

Decimal Arithmetic

(import decimal [Decimal])
(+ (Decimal "0.1") (Decimal "0.2"))  ; Decimal('0.3')

Or, if you define a reader macro (this is where Hy gets magical):

(defreader d
  (.slurp-space &reader)
  `(hy.I.decimal.Decimal ~(.read-ident &reader)))

;; Now you can write:
(+ #d .1 #d .2)  ; Decimal('0.3')

You just extended the language's syntax. #d .1 is now a Decimal literal. Try doing that in Python.


12. Virtual Environments and Packages

Hy uses the same virtual environments and package management as Python:

python -m venv myenv
source myenv/bin/activate
pip install hy
pip install requests flask numpy

Once installed, hy runs inside the venv with access to all installed packages. pip freeze, requirements.txt, pyproject.toml — everything works as expected because Hy doesn't replace the packaging ecosystem, it rides on top of it.


13. Floating-Point Arithmetic

Same hardware, same IEEE 754 doubles, same surprises:

=> (= (+ 0.1 0.2) 0.3)
False

=> (import math)
=> (math.isclose (+ 0.1 0.2) 0.3)
True

The decimal and fractions modules work identically:

(import fractions [Fraction])
(+ (Fraction 1 10) (Fraction 2 10))  ; Fraction(3, 10)
(= (+ (Fraction 1 10) (Fraction 2 10)) (Fraction 3 10))  ; True

14. The REPL Experience

Python's interactive interpreter supports readline history and tab completion. Hy's REPL provides the same, plus a few extras:

  • Colored output and syntax highlighting
  • Multi-line editing with proper paren matching
  • The hy2py command that shows you the Python equivalent of any Hy code
  • Direct evaluation of both Hy and Python expressions
$ echo '(defn greet [name] (print f"Hello, {name}!"))' | hy2py
def greet(name):
    print(f'Hello, {name}!')

hy2py is a fantastic learning tool. Write Hy, see exactly what Python it becomes.


15. The Secret Weapon: Macros

This section has no Python equivalent because Python doesn't have macros. This is what makes Hy more than "Python with parentheses."

What's a Macro?

A macro is a function that runs at compile time and returns code. It transforms your source before it's ever executed.

Example: do-while

Python doesn't have a do-while loop. In Hy, you can add one:

(defmacro do-while [condition #* body]
  `(do
    ~@body
    (while ~condition
      ~@body)))

;; Now use it:
(setv x 5)
(do-while (> x 0)
  (print x)
  (setv x (- x 1)))

This isn't a function pretending to be syntax. It's actual syntax. The macro expands at compile time into a do + while form. Zero runtime overhead.

Example: unless

(defmacro unless [condition #* body]
  `(when (not ~condition) ~@body))

(unless (= 1 2)
  (print "Math still works"))

Example: A Timing Utility

(defmacro time-it [#* body]
  `(do
    (import time)
    (setv start (time.time))
    ~@body
    (print f"Elapsed: {(- (time.time) start):.4f}s")))

(time-it
  (setv total (sum (range 1_000_000))))

Why This Matters

In Python, if you want a new control structure, you write a function that takes callbacks or use a decorator — workarounds that add complexity and indirection. In Hy, you write a macro that generates exactly the code you want. The result looks and behaves like a native language feature.

This is why Lisps have survived for 65+ years. The language adapts to you.


The Big Picture

DimensionPythonHy
RuntimeCPython bytecodeCPython bytecode (identical!)
SyntaxIndentation, keywordsS-expressions, parentheses
ParadigmMulti-paradigmMulti-paradigm + macros
Type SystemDynamicDynamic (same)
LibrariesPyPIPyPI (same)
MutabilityMutable by defaultMutable by default (same)
StatementsYes (if, for, with are statements)No (everything is an expression)
MetaprogrammingDecorators, metaclassesMacros, reader macros, plus decorators and metaclasses
Learning CurveLowLow if you know Python; parentheses take ~1 day
DebuggingStandard Python toolsStandard Python tools + hy2py
CommunityMassiveSmall but passionate

Closing Thoughts

Hy occupies a unique niche. It's not asking you to leave Python — it's asking you to see Python from a different angle. Every library you love still works. Every tool you rely on still applies. But now you also have macros, homoiconicity, and expression-oriented syntax.

Is it for everyone? No. If you're working on a team that knows Python, introducing Hy's syntax adds friction. If you're writing a script that other people will maintain, Python's readability wins.

But if you want to understand what Lisp programmers have been raving about for decades, without leaving the Python ecosystem you already know? If you want macros that write your boilerplate? If you want to treat code as data and data as code?

Hy is the gentlest possible on-ramp. You already know the semantics. You just need to learn the parentheses.

=> (print "Welcome to Hy. 🐍 meets ()")
Welcome to Hy. 🐍 meets ()

Get started: pip install hy and type hy.


Getting Started with a Real Project

pip install hy gets you the REPL, but for a real project you need non-obvious boilerplate — *.hy in package-data, hy as both a build and runtime dependency, and pytest-hy for test discovery. These project templates handle all of that:

Cookiecutter:

pip install cookiecutter
cookiecutter gh:kovan/cookiecutter-hy
cd my-hy-project
pip install -e .
my-hy-project

Poetry:

poetry self add poetry-hy-plugin
poetry new-hy my-project
cd my-project
poetry install
poetry run my-project

Both generate a ready-to-go project with main.hy, tests, and a properly configured pyproject.toml.


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 Hy's Lisp lens.