Why AI Hallucinates and What Technical Writing Can Do About It
Why AI systems hallucinate and how technical writing can help achieve more accurate results through structure, terminology and metadata. ➡️ Read now!
Why AI systems hallucinate and how technical writing can help achieve more accurate results through structure, terminology and metadata. ➡️ Read now!
I saw someone posted in the Clojurians Slack about something Clojure has taught them:
I’ve come to see programming as:
1. building simple systems
2. and building nice feedback loops for interacting with those systems.There is no three. I am so happy Clojure helps me with both.
—teodorlu
This is beautiful. It’s wonderful. And it’s a complete list, as far as I can tell.
I want to unpack these three points a litttle bit in the context of Clojure, partly to remind myself of these points, but also to better understand why we feel that Clojure is such a good teacher. And, yes, there are three points in the quote, which I’ll get to.
Clojure’s creator, Rich Hickey, has included the industry’s understanding of what simplicity means. After Simple Made Easy, we think of simplicity as a function of how well decomposed a thing is. We pull apart a problem until we understand how it is made of subproblems. This is the simplicity of making a thing to solve a problem at the appropriate level of granularity and generality. General solutions tend to be simpler.
My favorite example of this kind of simplicity from Clojure is the atom. It found a common problem—sharing mutable state between threads—and it solved it in a very straightforward way: You get one mutable reference with a very constrained interface. In return, you get a strong but limited guarantee. Hickey found the problem, separated it from the rest of the problems, and solved it in a minimal and useful way. His achievement of simplicity is inspiring.
Incidentally, I believe the careful decomposition of problems is why Clojure’s parts seem to work so well together. For example, atoms love to work with immutable data structures and update. They’re each solving small, related problems—safely sharing values (immutable data), working with nested structures (update), and changing the value over time (atom). I believe they compose well together because they were decomposed by the designer himself.
The Slack message mentions “simple systems”. In systems theory, according to Donella Meadows, we get a different definition of simplicity. There, simplicity is about the number and nature of feedback loops, delays, and nonlinear causal graphs—in short, interactions within the system between the parts. If you have fewer loops and branches, the system’s behavior is easier to understand.
Again, I’ll use the example of the atom. In many languages, we would use an object with mutable state with methods meant to read and modify that state. We might make a whole new class just to represent the count of the number of words in a directory of text files. And if multiple files are being counted in parallel, we’d need thread-safe coding practices, probably locks, to make sure we counted correctly. But in Clojure, it’s just an atom. It feels to me that the causal chain is much shorter, perhaps because the atom itself is so reliable. Locks are reliable, too, if you get them right. But you have to take the lock, do your work, then release. You need a try/finally so you release reliably, even after a failure. There’s a lot to get right. With an atom, you just:
(swap! count inc)
Dieter Rams is lauded as a master of simplicity. Many people conflate simplicity with minimalism. But Rams insists it is about clarity, not minimalism. The volume knob changes the volume. The on-off switch is clearly a toggle. The extreme focus on clarity can breed the aesthetic of minimalism.
Clojure too focuses on clarity. The clarity of purposed of each special form—if, let, etc.—is part of this form of simplicity. So too ar the function in the core library. Though there are some outliers, most functions reveal their purpose plainly. And they are so plain that even though there are many functions, their docs are shown on a simple page. When I look at the Javadocs for the standard libraries, I see staggering obscurity. Each class seems like a world of its own, ready to be studied. Methods return yet other classes—more worlds to understand.
Now let’s talk about feedback. Clojure excels at feedback. The obvious mechanism of feedback in Clojure is the REPL. The read-eval-print-loop is an interface between you and your code’s execution. A skilled programmer at the REPL will evaluate lots of expressions and subexpressions. You can recompile functions and run tests just as they would be executed in a production system.
But there are more subtle things that are easy to overlook. All of the literal data structures and atomic values like numbers, strings, keywords, and symbols, can be printed with no extra work. You can put a prn right in your program and print out the arguments to see them.
You can navigate the namespaces and perform reflection. The reflection works on Clojure (list all the vars in this namespace) and for JVM stuff (class, supers). These tools are a source of information about your code.
Clojure added tap> a few years ago. it’s a built-in pub/sub system used during development. Tools like Portal use it to get values from your running system and visualize them.
The third point should be unpacked, too. “There is no three.” It’s a point stated in the negative. It implicitly excludes all of the things that aren’t listed above. It’s sort of an invitation to abandon all the bad habits you picked up over the years. My bad habits include adding to many layers of indirection, trying to anticipate the future, and overmodeling. The rule to combat these is called YAGNI (You Ain’t Gonna Need It). Or DRY (Don’t Repeat Yourself). These are good practices that help you build simpler systems. But they’re all subsumed in the refocusing on the first two positive statements.
I think there’s value in enumerating the little rules of thumb this list leaves out, especially for beginners. As someone becomes an expert at something, the way they talk about their skill often sounds more and more abstract. “It’s just simplicity,” hides how hard simplicity is to achieve and even to understand. What sounds wise glosses over the thousands of details that you learned on the way up. That’s not to take away from the beauty of the expression. Just saying that these abstract expressions of what’s important leave a lot out.
That said, the reason the third item is so refreshing is that we’ve been taught at school and at work to code in a certain way. I was taught in Java to seek out the isA hierarchy inherent in a domain and to express it with a class hierarchy. It’s where we get the classic class Dog extends Animal. But it’s putting the cart before the horse. Yes, a dog is an animal. But is that relevant to my software? Saying “There is no three” gives me permission to stop and refocus on simplicity and feedback.
So thanks, Teodor, for sharing this. It’s a wonderful view into your progress as a programmer. You should be proud of all you’ve accomplished. I really like how you’ve boiled it down to these three ideas. It reminds me of how much I’ve learned from Clojure and how far I still have to go.
This is an update on the work I’ve done maintaining Clojars in January through March 2026 with the ongoing support of Clojurists Together.
Most of my work on Clojars is reactive, based on issues reported through
the community or noticed through monitoring. If you have any issues or
questions about Clojars, you can find me in the #clojars channel on
the Clojurians Slack,
or you can file an issue on the main Clojars GitHub
repository.
You can see the
CHANGELOG
for notable changes, and see all commits in the
clojars-web
and
infrastructure
repositories for this period. I also track my
work over the years for
Clojurists Together (and, before that, the Software Freedom
Conservancy).
Below are some highlights for work done in January through March:
I updated Maven index generation to use the database instead of downloading POMs from S3. This reduces the monthly hosting cost by ~100 USD/month, as we had to list the full repository bucket each time we wanted to sync. We have all the data we need in the database to do this, but the indexer takes pom files, so we just generate simple poms from the db.
I updated Clojars to use a fork of http-kit instead of Jetty. This removes Jetty as a dependency as we don’t need its complexity and it often brings in CVEs. We have to use a fork of http-kit to support custom status messages for validation failures (that’s the currently supported way to report them using the version of maven-resolver currently used by pomegranate).
I implemented RFC 9457 Problem Details responses on deploy validation failures in addition to status messages. This will be the new way to signal validation failures, once my PR to update pomegranate is merged, and we’re able to update leiningen and [deps-deploy](https://github.com/slipset/deps-deploy** to use that new version.
A note on 11 years of Clojars maintenance
I became the lead maintainer of Clojars a little over 11 years ago. I’ve done quite a bit of work on Clojars during that period, and have thoroughly enjoyed working on it & supporting the community! I greatly appreciate the support I’ve gotten from GitHub sponsors, the Software Freedom Conservancy, and Clojurists Together over the years. After all that, it’s time for a little break! I’m taking a few months away from Clojars (and computers in general) to go backpacking for a few months. I’m handing off lead maintenance to Daniel Compton, and it is in good hands!
Many thanks Toby for all your work - your contributions have made an immense difference to all of us! Have a great adventure - we’re looking forward to hearing all about it when you return.
Welcome to the Clojure Deref! This is a weekly link/news roundup for the Clojure ecosystem (feed: RSS).
September 30 – October 2, 2026
Charlotte Convention Center, Charlotte, NC
Join us for the largest gathering of Clojure developers in the world! Meet new people and reconnect with old friends. Enjoy two full days of talks, a day of workshops, social events, and more.
Early bird and group tickets are now on sale.
Is your company interested in sponsoring? Email us at clojure_conj@nubank.com.br to discuss opportunities.
Clojure real-world-data 55: Apr 10
Clojure Community Check-In: Apr 25
Babashka Conf: May 8. Amsterdam, NL. See the schedule.
Dutch Clojure Days 2026: May 9. Amsterdam, NL. See the schedule.
Spring Lisp Game Jam 2026: May 14-24. Online.
Clojure: The Documentary [OFFICIAL TRAILER] | Coming April 16th! 🚨 - CultRepo
Clojure - Zero to REPL (macOS) - ClojureTV
defn episode b8c570464f67 Siyoung - @defn podcast
The Making of Defeating Bowser with A* Search - phronmophobic
Learn Ring - 1. Why this Course? - Clojure Diary
Learn Ring - 2. Who Am I? - Clojure Diary
Learn Ring - 3. Prerequisite - Clojure Diary
Learn ring - 4. Ring Init - Clojure Diary
Learn Ring - 5. Dynamic Pages - Clojure Diary
Learn Ring - 6. Leiningen, Compojure, Jetty. - Clojure Diary
Learn Ring - 7. Templates - Clojure Diary
Learn Ring - 8. Hiccup - Clojure Diary
Clojars Update for Q1 2026 - Toby Crawley
Annually-Funded Developers' Update: January & February 2026 - Kathy Davis
Clojure - Clojure Zero to REPL Video - Jarrod Taylor
Searchable reference for Clojure, ClojureScript, and Babashka on ClojureStream - Jacek Schae
Don Clojure de la Mancha | Aprende Clojure en español - Andros Fenollosa
(nth (concat) 7) - Ana Carolina & Arthur Fücher
Negative Sets as Data – Clojure Civitas - Jonathan Claggett
The tools of an Agentic Engineer - David Vujic
Defining the architecture of the future electric grid — through open standards, policy advocacy, and working software. - Grid Coordination
On Functional Programming, Time, and Concurrency - Dustin Getz
Remote - ECA - Editor Code Assistant - Eric Dallo
Memory That Collaborates - Christian Weilbach
From Functions to Data - Evolving a Pull-Pattern API - Loic Blanchard
Clojure Protocols and the Decorator Pattern - Loic Blanchard
Translating non-trivial codebases with Claude - Daniel Janus
When You Run Out of Types… - Christophe Grand
Relaunching tablecloth.time: Composability over Abstraction – Clojure Civitas - Ethan Miller
I Vibe-Coded a Programming Language - Andriy Tyurnikov
Ayatori: An Experimental Agent Orchestration Engine in Clojure - Şeref Ayar
Building a Distributed RDF Store on Rama - Vladimir Mladenovic
Job-focused list of product companies using Clojure in production — 2026 - Yaroslav Podorvanov
The pain of microservices can be avoided, but not with traditional databases - Nathan Marz
Code, Mundane and Sublime - Eric Normand
Use the latest Dev Tools on a Stable Debian Linux - Practicalli
Vibe Coding Meets Vibe Ops: Automating the Last Mile of Deployment - Alberto Miorin
New Era for Clojure: Infix Syntax! - Flexiana - Jiri Knesl
Extending clojure.test with functions, not multimethods - Julien Vincent
Clojure on Fennel part one: Persistent Data Structures - Andrey Listopadov
Simple System + Rick Feedback - Eric Normand
Versioned Analytics for Regulated Industries - Christian Weilbach
Debut release
clj-android - A modernization of the clojure-android project.
plorer - cljfx/plorer helps you (or your coding agent) explore JavaFX application state in the REPL
xitdb-tsclj - Clojure flavored javascript using xitdb database
clj-mdns - Clojure wrapper around jmdns for mDNS service discovery
clj-oa3 - Clojure client library for OpenADR 3 (Martian HTTP, entity coercion, Malli schemas)
clj-oa3-client - Component lifecycle wrapper for clj-oa3 (MQTT, VEN registration, API delegation)
clj-gridx - Clojure client library for the GridX Pricing API
clj-midas - Clojure client library for the California Energy Commission’s MIDAS API
flux - Clojure wrapper for Netflix concurrency-limits — adaptive concurrency control based on TCP congestion algorithms.
ClojureProtegeIDE - GitHub - rururu/ClojureProtegeIDE
re-frame-query - Declarative data fetching and caching for re-frame inspired by tanstack query and redux toolkit query
codox-md - Codox writer that generates Markdown documentation for embedding in Clojure JARs
clj-doc-browse - Runtime classpath-based Markdown documentation browser for Clojure libraries
clj-doc-browse-el - Emacs package for browsing Clojure library docs from classpath JARs via CIDER
llx - Unified LLM API and agent runtime for Clojure, ClojureScript (and soon Clojure Dart)
baredom - BareDOM: Lightweight CLJS UI components built on web standards (Custom Elements, Shadow DOM, ES modules). No framework, just the DOM
ty-pocketledger - Demo app for ty web components over datastar that can be installed on mobile device
noumenon - Queryable knowledge graph for codebases — turns git history and LLM-analyzed source into a Datomic database that AI agents can query with Datalog.
lasagna-pattern - Match data with your pattern
rama-sail-graph - Demonstration of Rama and RDF4J SAIL API integration
clua - Sandboxed Lua 5.5 interpreter for Clojure/JVM
awesome-backseat-driver - Plugin marketplace for Clojure AI context in GitHub Copilot: agents, skills, and workflows for REPL-first interactive programming with Calva Backseat Driver
dexter - Dexter - Graphical Dependency Explorer
meme-clj - meme-clj — M-Expressions with Macro Expansion
xor-clj - Train neural network to imitate XOR operator using Clojure libpython-clj and Pytorch
mdq - A faithful port of Rust mdq, jq for markdown to Babashka.
once - BigConfig and ONCE
clj-format - A Clojure DSL for cl-format inspired by Hiccup. No dependencies. Drop-in compatibility. The power of FORMAT made easy.
infix - Readable Math and Data Processing for Clojure
ansatz - Dependently typed Clojure DSL with a Lean4 compatible kernel.
k7 - A high-performance disk-backed queue for Clojure
eido - Data-driven 2D & 3D graphics for Clojure — shapes, animation, lighting, and compositing from pure data
html2helix - Convert raw HTML to ClojureScript Helix syntax
Updates
clojure 1.12.5-alpha1 - The Clojure programming language
core.async 1.9.865 - Facilities for async programming and communication in Clojure
core.async 1.10.870-alpha2 - Facilities for async programming and communication in Clojure
ring-swagger 1.1.0 - Swagger Spec for Clojure Web Apps
bling 0.10.0 - Rich text console printing for Clojure, ClojureScript, and Babashka.
fireworks 0.20.0 - Fireworks is a themeable tapping library for Clojure, ClojureScript, and Babashka.
lasertag 0.12.0 - Lasertag is a utility library for categorizing values in Clojure, ClojureScript, and Babashka
glojure 0.6.5-rc15 - Clojure interpreter hosted on Go, with extensible interop support.
polylith-external-test-runner 0.8.0 - An external (subprocess) test runner for Polylith
zodiac-assets 0.6.48 - A Zodiac extension to help manage static assets with vite
clj-figlet 0.1.4 - A native Clojure re-implementation of FIGlet — the classic ASCII art text renderer.
cursive 2026.1 - Cursive: The IDE for beautiful Clojure code
clojure-mode 5.23.0 - Emacs support for the Clojure(Script) programming language
quickdoc 0.2.6 - Quick and minimal API doc generation for Clojure
jsonista 1.0.0 - Clojure library for fast JSON encoding and decoding.
pants_backend_clojure 0.2.1 - Pants build tool backend for Clojure
injest 0.1.0-beta.9 - +>, +>>, x>>, ⇒>: Auto-transducifying, auto-parallelizing path thread macros
build-uber-log4j2-handler 2.25.4 - A conflict handler for log4j2 plugins cache files for the tools.build uber task.
medley 1.10.0 - A lightweight library of useful Clojure functions
lazytest 2.0.0 - A standalone BDD test framework for Clojure
nvim-astro5 2026-03-31 - Neovim 0.11 config for Clojure development, based on AstroNvim v5
pavlov beta - Behavioral Programming for Clojure
ring-hot-reload 0.2.9 - A Clojure Ring middleware for hot loading changes into the browser
tablecloth 8.0.16 - Dataset manipulation library built on the top of tech.ml.dataset
tape 1.0.0-alpha47 - Chronicle Queue library for Clojure
shadow-cljs-vite-plugin 0.0.9 - A robust Vite plugin for seamless integration with shadow-cljs
calva 2.0.570 - Clojure & ClojureScript Interactive Programming for VS Code
calva-backseat-driver 0.0.28 - VS Code AI Agent Interactive Programming. Tools for CoPIlot and other assistants. Can also be used as an MCP server.
edamame 1.5.39 - Configurable EDN/Clojure parser with location metadata
markdown 0.7.225 - A cross-platform Clojure/Script parser for Markdown
babashka-sql-pods 0.1.6 - Babashka pods for SQL databases
splint 1.24.0 - A Clojure linter focused on style and code shape.
superficie 0.2.28 - Surface syntax for Clojure to help exposition/onboarding.
livewire 0.11.0 - Embedded nREPL wire into a running Spring Boot app — giving AI agents and humans a live probe into the JVM. Inspect beans, trace SQL, detect N+1s, and hot-swap @Query annotations. Zero restarts.
clojisr 1.1.0 - Clojure speaks statistics - a bridge between Clojure to R
stratum 0.2.51 - Versioned, fast and scalable columnar database.
datomic-pro-manager 1.1.0 - Download, setup, and run Datomic Pro backed by SQLite in a single command.
plumcp 0.2.0-rc1 - Clojure/ClojureScript library for making MCP server and client
statecharts 1.4.0-RC10 - A Statechart library for CLJ(S)
Clojure 1.12.5-alpha1 is now available! Find download and usage information on the Downloads page.
Somewhere in 2019 I started a project that aimed to bring some of Clojure features to Lua runtime - fennel-cljlib.
It was a library for Fennel that implemented a basic subset of clojure.core namespace functions and macros.
My goal was simple - I enjoy working with Clojure, but I don’t use it for hobby projects, so I wanted Fennel to feel more Clojure-like, besides what it already provides for that.
This library grew over the years, I implemented lazy sequences, added immutability, made a testing library, inspired by clojure.test and kaocha, and even made a port of clojure.core.async.
It was a passion project, I almost never used it to write actual software.
One notable exception is fenneldoc - a tool for documentation generation for Fennel libraries.
And I haven’t seen anyone else use it for a serious project.
The reason for that is simple - it was an experiment. Corners were cut, and Fennel, being a Clojure-inspired lisp is not associated with functional programming the same way Clojure is. As a matter of fact, I wouldn’t recommend using this library for anything serious… yet.
Recently, however, I started a new project: ClojureFnl.
This is a Clojure-to-Fennel compiler that uses fennel-cljlib as a foundation.
It’s still in early days of development, but I’ve been working on it for a few months in private until I found a suitable way to make things work in March.
As of this moment, it is capable of compiling most of .cljc files I threw at it, but running the compiled code is a different matter.
I mean, it works to some degree, but the support for standard library is far from done.
;; Welcome to ClojureFnl REPL
;; ClojureFnl v0.0.1
;; Fennel 1.6.1 on PUC Lua 5.5
user=> (defn prime? [n]
(not (some zero? (map #(rem n %) (range 2 n)))))
#<function: 0x89ba7c550>
user=> (for [x (range 3 33 2)
:when (prime? x)]
x)
(3 5 7 11 13 17 19 23 29 31)
user=>
However, there was a problem.
My initial implementation of immutable data structures in the itable library had a serious flaw. The whole library was a simple hack based on the copy-on-write approach and a bunch of Lua metatables to enforce immutability. As a result, all operations were extremely slow. It was fine as an experiment, but if I wanted to go further with ClojureFnl, I had to replace it. The same problem plagued immutableredblacktree.lua, an implementation of a copy-on-write red-black tree I made for sorted maps. It did a full copy of the tree each time it was modified.
For associative tables it wasn’t that big of a deal - usually maps contain a small amount of keys, and itable only copied levels that needed to be changed.
So, if you had a map with, say, ten keys, and each of those keys contained another map with ten keys, adding, removing or updating a key in the outer map meant only copying these ten keys - not the whole nested map.
I could do that reliably, because inner maps were immutable too.
But for arrays the story is usually quite different. Arrays often store a lot of indices, and rarely are nested (or at least not as often as maps). And copying arrays on every change quickly becomes expensive. I’ve mitigated some of the performance problems by implementing my version of transients, however the beauty of Clojure’s data structures is that they’re quite fast even without this optimization.
Clojure uses Persistent HAMT as a base for its hash maps and sets, and a bit-partitioned trie for vectors. For sorted maps and sets, Clojure uses an immutable red-black tree implementation, but as far as I know it’s not doing a full copy of the tree, and it also has structural sharing properties.
I started looking into existing implementations of HAMT for Lua:
I could use one of those, notably ltrie seemed the most appropriate one, but given that I’m working on a fennel library that I want later to embed into my Clojure compiler I needed a library implemented in Fennel.
So I made my own library: immutable.fnl. This library features HAMT-hash maps, hash-sets, and vectors, as well as a better implementation of a persistent red-black tree, and lazy linked lists.
I started the implementation with a Persistent HAMT with native Lua hashing. The data structure itself is a Hash Array Mapped Trie (HAMT) with 16-factor branching. Thus all operations are O(Log16 N), which is effectively O(1) for a practical amount of keys.
As far as I know, Clojure uses branching factor of 32, but for a Lua runtime this would mean that the popcount would be more expensive, and despite a shallower tree, each mutation would need to copy a larger sparse array. With branching factor of 16 a map with 50K entries is ~4 levels deep, which would be ~3 with 32 branching factor. So my logic was that it’ll be a compromise, especially since Lua is not JVM when it comes to performance.
Of course, it’s not as fast as a pure Lua table, which is to be expected. Lua tables are implemented in C, use efficient hashing, and dynamically re-allocated based on key count. So for my implementation most operations are a lot slower, but the total time for an operation is still usable.
Here are some benchmarks:
Median time over 7 rounds (1 warmup discarded), N = 50000 elements. GC stopped during measurement. Clock: os.clock (CPU). Runtime: Fennel 1.7.0-dev on PUC Lua 5.5
Regular operations are notably slower, when compared to Lua:
| Operation | Lua table | Persistent HashMap | Ratio | per op |
|---|---|---|---|---|
| insert 50000 random keys | 2.05 ms | 164.80 ms | 80.3x slower | 3.3 us |
| lookup 50000 random keys | 0.83 ms | 92.51 ms | 110.8x slower | 1.9 us |
| delete all | 0.78 ms | 170.78 ms | 219.8x slower | 3.4 us |
| delete 10% | 0.14 ms | 19.50 ms | 136.4x slower | 3.9 us |
| iterate 50000 entries | 1.74 ms | 6.64 ms | 3.8x slower | 0.133 us |
For transients the situation is a bit better, but not by much:
| Operation | Lua table | Transient HashMap | Ratio | per op |
|---|---|---|---|---|
| insert 50000 random keys | 2.05 ms | 89.17 ms | 43.5x slower | 1.8 us |
| delete all | 0.76 ms | 104.31 ms | 138.0x slower | 2.1 us |
| delete 10% | 0.16 ms | 12.71 ms | 82.0x slower | 2.5 us |
On LuaJIT numbers may seem worse, but per-operation cost is much lower, it’s just that native table operations are so much faster:
Median time over 7 rounds (1 warmup discarded), N = 50000 elements. GC stopped during measurement. Clock: os.clock (CPU). Runtime: Fennel 1.7.0-dev on LuaJIT 2.1.1774896198 macOS/arm64
| Operation | Lua table | Persistent HashMap | Ratio | per op |
|---|---|---|---|---|
| insert 50000 random keys | 0.86 ms | 49.05 ms | 56.8x slower | 0.981 us |
| lookup 50000 random keys | 0.27 ms | 14.21 ms | 53.4x slower | 0.284 us |
| delete all | 0.13 ms | 48.63 ms | 374.1x slower | 0.973 us |
| delete 10% | 0.05 ms | 6.49 ms | 138.1x slower | 1.3 us |
| iterate 50000 entries | 0.07 ms | 1.80 ms | 27.7x slower | 0.036 us |
| Operation | Lua table | Transient HashMap | Ratio | per op |
|---|---|---|---|---|
| insert 50000 random keys | 0.76 ms | 22.43 ms | 29.6x slower | 0.449 us |
| delete all | 0.15 ms | 34.16 ms | 232.4x slower | 0.683 us |
| delete 10% | 0.04 ms | 5.02 ms | 132.1x slower | 1.0 us |
With a branching factor of 32 the situation gets worse on PUC Lua, but is slightly better on LuaJIT. So there’s still space for fine-tuning.
For hashing strings and objects I decided to use djb2 algorithm.
I am almost as old as this hash function, so seemed like a good fit.
JK.
The main reason to use it was that it can be implemented even if we don’t have any bit-wise operators, and Lua doesn’t have them in all of the versions.
It only uses +, *, and % arithmetic operators, so can be done on any Lua version.
It’s prone to collisions, and I try to mitigate that by randomizing it when the library is loaded.
Still, collisions do happen, but HAMT core ensures that they will still resolve correctly by implementing a deep equality function for most objects.
However, when first working on this, I noticed this:
>> (local hash-map (require :io.gitlab.andreyorst.immutable.PersistentHashMap))
nil
>> (local {: hash} (require :io.gitlab.andreyorst.immutable.impl.hash))
nil
>> (hash (hash-map :foo 1 :bar 2))
161272824
>> (hash {:foo 1 :bar 2})
161272824
>> (hash-map (hash-map :foo 1 :bar 2) 1 {:foo 1 :bar 2} 2)
{{:foo 1 :bar 2} 2}
This is an interesting loophole. What object ended up in our hash map as a key - our persistent map or plain Lua table? Well, that depends on insertion order:
>> (each [_ k (pairs (hash-map (hash-map :foo 1 :bar 2) 1 {:foo 1 :bar 2} 2))]
(print (getmetatable k)))
IPersistentHashMap: 0x824d9b570
nil
>> (each [_ k (pairs (hash-map {:foo 1 :bar 2} 2 (hash-map :foo 1 :bar 2) 1))]
(print (getmetatable k)))
nil
nil
To reiterate, I’m creating a hash map, with a key set to another persistent hash map, and then insert a plain Lua table with the same content. The Lua table hashes to exactly the same hash, and goes into the same bucket, but there’s no collision, because objects are equal by value. But equality of mutable collections is very loosely defined - it may be equal right now, but the next time you look at it, it’s different. So a different hashing was needed for persistent collections, to avoid these kinds of collision. I ended up salting persistent collections with their prototype address in memory.
Other than that, the HAMT implementation is by the book, and the rest is the interface for interacting with maps.
Main operations:
new - construct a new map of key value pairsassoc - associate a key with a valuedissoc - remove key from the mapconj - universal method for association, much like in Clojurecontains - check if key is in the mapcount - map size, constant timeget - get a key value from a mapkeys - get a lazy list of keysvals - get a lazy list of valuestransient - convert a map to a transientCoercion/conversion:
from - create a map from another objectto-table - convert a map to a Lua tableiterator - get an iterator to use in Lua loopsTransient operations:
assoc! - mutable assocdissoc! - mutable dissocpersistent - convert back to persistent variant, and mark transient as completedThis covers most of the needs in my fennel-cljlib library, as anything besides it I can implement myself, or just adapt existing implementations.
A Persistent Hash Set is also available as a thin wrapper around PersistentHashMap with a few method changes.
A note on
PersistentArrayMap.In Clojure there is a second kind of maps that are ordered, not sorted, called a Persistent Array Map. They are used by default when defining a map with eight keys or less, like
{:foo 1 :bar 2}. The idea is simple - for such a small map, a linear search through all keys is faster than with a HAMT-based map.However, in my testing on the Lua runtime, there’s no benefit in this kind of a data structure, apart from it being an ordered variant. Lookup is slower, because of a custom equality function, which does deep comparison.
Persistent Vectors came next, and while the trie structure is similar to hash maps, vectors use direct index-based navigation instead of hashing, with a branching factor of 32. Unlike maps, vector arrays in the HAMT are more densely packed, and therefore a higher branching factor is better for performance. So lookup, update, and pop are O(log32 N), append can be considered O(1) amortized.
Still, compared to plain Lua sequential tables the performance is not as good:
Median time over 7 rounds (1 warmup discarded), N = 50000 elements. GC stopped during measurement. Clock: os.clock (CPU). Runtime: Fennel 1.7.0-dev on PUC Lua 5.5
| Operation | Lua table | Persistent Vector | Ratio | per op |
|---|---|---|---|---|
| insert 50000 elements | 0.19 ms | 21.07 ms | 109.7x slower | 0.421 us |
| lookup 50000 random indices | 0.47 ms | 14.05 ms | 29.7x slower | 0.281 us |
| update 50000 random indices | 0.32 ms | 70.04 ms | 221.6x slower | 1.4 us |
| pop all 50000 elements | 0.25 ms | 24.34 ms | 96.2x slower | 0.487 us |
| iterate 50000 elements | 0.63 ms | 10.16 ms | 16.2x slower | 0.203 us |
| Operation | Lua table | Transient Vector | Ratio | per op |
|---|---|---|---|---|
| insert 50000 elements | 0.19 ms | 7.81 ms | 40.3x slower | 0.156 us |
| update 50000 random indices | 0.33 ms | 20.76 ms | 62.4x slower | 0.415 us |
| pop all 50000 elements | 0.25 ms | 11.14 ms | 44.4x slower | 0.223 us |
On LuaJIT:
Median time over 7 rounds (1 warmup discarded), N = 50000 elements. GC stopped during measurement. Clock: os.clock (CPU). Runtime: Fennel 1.7.0-dev on LuaJIT 2.1.1774896198 macOS/arm64
| Operation | Lua table | Persistent Vector | Ratio | per op |
|---|---|---|---|---|
| insert 50000 elements | 0.10 ms | 7.62 ms | 74.0x slower | 0.152 us |
| lookup 50000 random indices | 0.06 ms | 0.67 ms | 11.8x slower | 0.013 us |
| update 50000 random indices | 0.04 ms | 29.13 ms | 710.4x slower | 0.583 us |
| pop all 50000 elements | 0.02 ms | 8.62 ms | 410.4x slower | 0.172 us |
| iterate 50000 elements | 0.02 ms | 0.57 ms | 28.7x slower | 0.011 us |
| Operation | Lua table | Transient Vector | Ratio | per op |
|---|---|---|---|---|
| insert 50000 elements | 0.05 ms | 0.59 ms | 11.6x slower | 0.012 us |
| update 50000 random indices | 0.04 ms | 2.06 ms | 51.6x slower | 0.041 us |
| pop all 50000 elements | 0.02 ms | 0.84 ms | 46.7x slower | 0.017 us |
I think this is an OK performance still. Vectors don’t use hashing, instead it is a direct index traversal via bit-shifting, so there’s no hashing, just index math.
Operations on vectors include:
new - constructorconj - append to the tailassoc - change a value at given indexcount - element count (constant time)get - get value at given indexpop - remove lasttransient - convert to a transientsubvec - create a slice of the vector in constant timeTransient operations:
assoc! - mutable assocconj! - mutable conjpop! - mutable poppersistent - convert back to persistent and finalizeInterop:
from - creates a vector from any other collectioniterator - returns an iterator for use in Lua loopsto-table - converts to a sequential Lua tableOne notable difference in both vector and hash-map is that it allows nil to be used as a value (and as a key, in case of the hash-map).
Vectors don’t have the same problem that Lua sequential tables have, where length is not well-defined if the table has holes in it.
It’s a debate for another time, whether allowing nil as a value (and especially as a key) is a good decision to make, but Clojure already made it for me.
So for this project I decided to support it.
For sorted maps and sorted sets I chose Okasaki’s insertion and Germane & Might’s deletion algorithms. Most of the knowledge I got from this amazing blog post by Matt Might.
I believe the operations are O(Log N), as for any binary tree, but given that the deletion algorithm is tricky, I’m not exactly sure:
Median time over 7 rounds (1 warmup discarded), N = 50000 elements. GC stopped during measurement. Clock: os.clock (CPU). Runtime: Fennel 1.7.0-dev on PUC Lua 5.5
| Operation | Lua table | PersistentTreeMap | Ratio | per op |
|---|---|---|---|---|
| insert 50000 random keys | 2.10 ms | 209.23 ms | 99.8x slower | 4.2 us |
| lookup 50000 random keys | 0.88 ms | 82.97 ms | 94.2x slower | 1.7 us |
| delete all | 0.74 ms | 173.76 ms | 234.8x slower | 3.5 us |
On LuaJIT:
Median time over 7 rounds (1 warmup discarded), N = 50000 elements. GC stopped during measurement. Clock: os.clock (CPU). Runtime: Fennel 1.7.0-dev on LuaJIT 2.1.1774896198 macOS/arm64
| Operation | Lua table | PersistentTreeMap | Ratio | per op |
|---|---|---|---|---|
| insert 50000 random keys | 0.72 ms | 101.08 ms | 140.4x slower | 2.0 us |
| lookup 50000 random keys | 0.25 ms | 12.67 ms | 49.9x slower | 0.253 us |
| delete all | 0.14 ms | 56.14 ms | 403.9x slower | 1.1 us |
The API for sorted maps and sets is the same as to their hash counterparts with a small difference - no transients. Clojure doesn’t do them, and I’m not doing them too.
That’s all for benchmarks.
I know that there are many problems with this kind of benchmarking, so take it with a grain of salt.
Still, the results are far, far better than what I had with itable.
But there are two more data structures to talk about.
As I mentioned, I made a lazy persistent list implementation a while ago but it had its problems and I couldn’t integrate that library with the current one well enough.
The main problem was that this library uses a single shared metatable per data structure, and the old implementation of lazy lists didn’t. This difference makes it hard to check whether the object is a table, hash-map, list, vector, set, etc. So I reimplemented them.
The reason for old implementation to use different metatables was because I decided to try the approach described in Reversing the technical interview post by Kyle Kingsbury (Aphyr). I know this post is more of a fun joke, but it actually makes sense to define linked lists like that in Lua.
See, tables are mutable, and you can’t do much about it.
Closures, on the other hand are much harder to mutate - you can still do it via the debug module, but it’s hard, and it’s not always present.
So storing head and tail in function closures was a deliberate choice.
However, it meant that I needed to somehow attach metadata to the function, to make it act like a data structure, and you can’t just use setmetatable on a function.
Again, you can do debug.setmetatable but all function objects share the same metadata table.
So, while you can do fancy things like this:
>> (fn comp [f g] (fn [...] (f (g ...))))
#<function: 0x7bdb320a0>
>> (debug.setmetatable (fn []) {:__add comp})
#<function: 0x7bd17f040>
>> ((+ string.reverse string.upper) "foo")
"OOF"
You can also notice, that our + overload applied to functions in the string module.
So instead, we use a table, and wrap it with a metatable that has a __call metamethod, essentially making our table act like a function.
This, in turn means, that we have to create two tables per list node - one to give to the user, the other to set our __call and use it as a meta-table.
Convoluted, I know.
It’s all in the past now - current implementation is a simple {:head 42 :tail {...}} table.
Not sure what is worse.
But that meant that I had to rework how lazy lists worked, because previously it was just a metatable swap.
Now list stores a “thunk”, that when called replaces itself in the node with the :head and :tail keys.
Unless it’s an empty list, of course - in that case we swap the metatable to an empty list one.
So Lists have three metatables now:
IPersistentListIPersistentList$EmptyIPersistentList$LazyInstead of god knows how many in the old implementation.
The list interface is also better now. Previously it was hardcoded how to construct a list from a data structure. Current implementation also hardcodes it, but also allows to build a list in a lazy way from an iterator.
This is better, because now a custom data structure that has weird iteration schema (like maps and sets in this library), we still can convert it to a list. A general case is just:
(PersistentList.from-iterator #(pairs data) (fn [_ v] v))
Meaning that we pass a function that will produce the iterator, and a function to capture values from that iterator. Reminds me of clojure transducers in some way.
And the final data structure - a persistent queue. Fast append at the end, and also fast remove from the front.
It’s done by holding two collections - a linked list at the front, and a persistent vector for the rear. So removing from the list is O(1), and appending to the vector is also pretty much O(1).
Interesting things start to happen when we exhaust the list part - we need to move vector’s contents into the list.
It is done by calling PersistentList.from on the rear.
And building a list out of a persistent vector is an O(1) operation as well!
Well, because nothing happens, we simply create an iterator, and build the list in a lazy way.
But since indexing the vector is essentially ~O(1), we can say that we still retain this property.
Or at least that’s how I reasoned about this - I’m not that good with time-complexity stuff.
That concludes part one about ClojureFnl.
I know that this post was not about ClojureFnl at all, but I had to fix my underlying implementation first. Now, that I have better data structures to build from, I can get back working on the compiler itself. So the next post will hopefully be about the compiler itself.
Unless I get distracted again.
Financial regulation — Basel III, MiFID II, Solvency II, SOX — requires that risk calculations, credit decisions, and compliance reports be reproducible. Not just the code, but the exact data state that produced them. When an auditor asks “show me the data behind this risk number from six months ago,” the answer can’t be “we’ll try to reconstruct it.”
Version control solved this problem for source code decades ago. But analytical data infrastructure never caught up. Data warehouses don’t version tables. Temporal tables track row-level changes but don’t compose across tables or systems. Manual snapshots are expensive, fragile, and don’t support branching for scenario analysis.
Stratum brings the git model to analytical data: every write creates an immutable, content-addressed snapshot. Old states remain accessible by commit UUID. Branches are O(1). And via Yggdrasil, you can tie entity databases, analytical datasets, and search indices into a single consistent, auditable snapshot.
A typical analytical pipeline at a regulated institution:
Step 4 is where things break. The warehouse has been mutated since then. Maybe there’s a backup, maybe not. Reconstructing the exact state requires replaying ETL from source systems — if those logs still exist.
Even if you can reconstruct the data, you can’t prove it’s the same data. There’s no cryptographic link between the report and the state that produced it. The best you can offer is procedural trust: “our backup process is reliable, and we believe this is what the data looked like.” That’s a weak foundation for regulatory compliance.
With Stratum, every table is a copy-on-write value. Writes create new snapshots; old snapshots remain addressable by commit UUID or branch name. The underlying storage is a content-addressed Merkle tree — each snapshot’s identity is derived from a hash of its data, providing a cryptographic chain of custody from report to source.
require('[stratum.api :as st])
;; Load the current production state
def trades: st/load(store "trades" {:branch "production"})
;; Run today's risk calculation
def risk-report: st/q({:from trades, :group [:desk :currency], :agg [[:sum :notional] [:stddev :pnl] [:count]]})
;; The commit UUID is your audit anchor — store it alongside the report
;; Six months later, reproduce exactly:
def historical-trades: st/load(store "trades" {:as-of #uuid "a1b2c3d4-..."})
def historical-report: st/q({:from historical-trades, :group [:desk :currency], :agg [[:sum :notional] [:stddev :pnl] [:count]]})
;; Identical results, guaranteed by content addressing
(require '[stratum.api :as st])
;; Load the current production state
(def trades (st/load store "trades" {:branch "production"}))
;; Run today's risk calculation
(def risk-report
(st/q {:from trades
:group [:desk :currency]
:agg [[:sum :notional] [:stddev :pnl] [:count]]}))
;; The commit UUID is your audit anchor — store it alongside the report
;; Six months later, reproduce exactly:
(def historical-trades
(st/load store "trades" {:as-of #uuid "a1b2c3d4-..."}))
(def historical-report
(st/q {:from historical-trades
:group [:desk :currency]
:agg [[:sum :notional] [:stddev :pnl] [:count]]}))
;; Identical results, guaranteed by content addressing
Or via SQL — connect any PostgreSQL client:
-- Today's report
SELECT desk, currency, SUM(notional), STDDEV(pnl), COUNT(*)
FROM trades GROUP BY desk, currency;
-- Historical report: same query, different snapshot
-- resolved server-side via branch/commit configuration
Once committed, data cannot be modified — every state is a value, addressable by its content hash. Historical snapshots load lazily from storage on demand, so keeping years of history doesn’t mean paying for it in memory. And because snapshots are immutable values, multiple analysts can query the same or different points in time concurrently without coordination or locks.
Beyond audit compliance, regulated institutions need scenario analysis. Basel III stress testing requires banks to evaluate capital adequacy under hypothetical adverse conditions — equity drawdowns, interest rate shocks, credit spread widening. Traditional approaches involve copying production data into staging environments, running scenarios, comparing results, and cleaning up. That process is slow, expensive, and error-prone.
With copy-on-write branching, forking a dataset is O(1) regardless of size. A 100-million-row table branches in microseconds because the fork is just a new root pointer into the shared tree. Only chunks that are actually modified get copied.
;; Fork production data for stress testing — O(1) regardless of table size
def stress-scenario: st/fork(trades)
;; Apply adverse conditions — only modified chunks are copied
;; e.g. via SQL: UPDATE trades SET price = price * 0.7
;; WHERE asset_class = 'equity'
;; Compare risk metrics: production vs stressed
def baseline-risk: st/q({:from trades, :group [:desk], :agg [[:stddev :pnl] [:sum :notional]]})
def stressed-risk: st/q({:from stress-scenario, :group [:desk], :agg [[:stddev :pnl] [:sum :notional]]})
;; Run as many scenarios as needed — each is an independent branch
;; Baseline, adverse, severely adverse, custom scenarios
;; all sharing unmodified data via structural sharing
;; Fork production data for stress testing — O(1) regardless of table size
(def stress-scenario (st/fork trades))
;; Apply adverse conditions — only modified chunks are copied
;; e.g. via SQL: UPDATE trades SET price = price * 0.7
;; WHERE asset_class = 'equity'
;; Compare risk metrics: production vs stressed
(def baseline-risk
(st/q {:from trades
:group [:desk]
:agg [[:stddev :pnl] [:sum :notional]]}))
(def stressed-risk
(st/q {:from stress-scenario
:group [:desk]
:agg [[:stddev :pnl] [:sum :notional]]}))
;; Run as many scenarios as needed — each is an independent branch
;; Baseline, adverse, severely adverse, custom scenarios
;; all sharing unmodified data via structural sharing
Each branch is fully isolated: modifications to the stress scenario can’t touch production data. You can maintain dozens of concurrent scenarios without multiplying storage costs — they share all unmodified data. When you stop referencing a branch, mark-and-sweep GC reclaims the storage. No staging environments, no cleanup scripts.
This also applies to model validation. When a risk model is updated, you can run the new model against historical snapshots and compare its outputs to the original model’s results — same data, different code, verifiable divergence.
A real regulatory pipeline isn’t just one analytical table. Entity data (customers, counterparties, legal entities) lives in a transactional database. Analytical views (positions, P&L, exposures) live in a columnar engine. Compliance documents and communications live in a search index. For an audit to be meaningful, all of these need to be at the same point in time.
Yggdrasil provides a shared branching protocol across these heterogeneous systems. You can compose a Datahike entity database, a Stratum analytical dataset, and a Scriptum search index into a single composite system — branching, snapshotting, and time-traveling all of them together.
require('[yggdrasil.core :as ygg])
;; Compose entity database + analytics + search into one system
def system: ygg/composite-system({:entities datahike-conn, :analytics stratum-store, :search scriptum-index})
;; Branch the entire system for an investigation
ygg/branch!(system "investigation-2026-Q1")
;; Every component is now at the same logical point in time
;; Query across all three with a single consistent snapshot
(require '[yggdrasil.core :as ygg])
;; Compose entity database + analytics + search into one system
(def system
(ygg/composite-system
{:entities datahike-conn ;; customer records, counterparties
:analytics stratum-store ;; trade data, positions, P&L
:search scriptum-index})) ;; compliance documents, communications
;; Branch the entire system for an investigation
(ygg/branch! system "investigation-2026-Q1")
;; Every component is now at the same logical point in time
;; Query across all three with a single consistent snapshot
When an auditor needs the full picture — the trade data, the customer entity that placed the trade, and the compliance documents reviewed at the time — they get a single consistent view across all systems, tied to one branch identifier. No manual coordination, no hoping the timestamps line up.
Immutable systems raise an obvious question: what about GDPR right-to-erasure, or data retention policies that require deletion?
Immutability doesn’t mean data can never be removed — it means deletion is explicit and verifiable rather than implicit and unauditable. The Datahike ecosystem supports purge operations that remove specific data from all indices and all historical snapshots. Mark-and-sweep garbage collection, coordinated across systems via Yggdrasil, reclaims storage from unreachable snapshots.
This is actually a stronger compliance story than mutable databases offer. In a mutable system, you DELETE a row and trust that the storage layer eventually overwrites it — but you can’t prove it’s gone from backups, replicas, or caches. With explicit purge on content-addressed storage, you can verify that the data no longer exists in any reachable snapshot.
Versioning and immutability don’t come at the cost of query speed. Stratum uses SIMD-accelerated execution via the Java Vector API, fused filter-aggregate pipelines, and zone-map pruning to skip entire data chunks. It runs standard OLAP benchmarks competitively with engines like DuckDB — while also providing branching, time travel, and content addressing that pure analytical engines don’t.
Full SQL is supported via the PostgreSQL wire protocol: aggregates, window functions, joins, CTEs, subqueries. Connect with psql, JDBC, DBeaver, or any PostgreSQL-compatible client. See the Stratum technical deep-dive for architecture details and benchmark methodology.
Stratum runs as an in-process Clojure library or a standalone SQL server. Requires JDK 21+.
{:deps {org.replikativ/stratum {:mvn/version "RELEASE"}}}
{:deps {org.replikativ/stratum {:mvn/version "RELEASE"}}}
If you’re building analytical infrastructure in a regulated environment — or exploring how versioned data can simplify your compliance story — get in touch. We work with teams in finance, insurance, and healthcare to design data architectures where auditability is built in, not bolted on.
Code
project.clj(defproject little_ring_things "0.1.0-SNAPSHOT"
:description "FIXME: write description"
:url "http://example.com/FIXME"
:min-lein-version "2.0.0"
:dependencies [[org.clojure/clojure "1.12.4"]
[compojure "1.6.1"]
[ring/ring-defaults "0.3.2"]
[hiccup "2.0.0"]]
:plugins [[lein-ring "0.12.5"]]
:ring {:handler little-ring-things.handler/app}
:profiles
{:dev {:dependencies [[javax.servlet/servlet-api "2.5"]
[ring/ring-mock "0.3.2"]]}})
template.clj(ns little-ring-things.template
(:require [hiccup2.core :as h]))
(defn template [title body]
(str
"<!DOCTYPE html>\n"
(h/html
[:html
[:head
[:meta {:charset "UTF-8"}]
[:title title]]
[:body
body]])))
Notes
This post walks through a small web development project using Clojure, covering everything from building the app to packaging and deploying it. It’s a collection of insights and tips I’ve learned from building my Clojure side projects, but presented in a more structured format.
As the title suggests, we’ll be deploying the app to Fly.io. It’s a service that allows you to deploy apps packaged as Docker images on lightweight virtual machines. [1] [1] My experience with it has been good; it’s easy to use and quick to set up. One downside of Fly is that it doesn’t have a free tier, but if you don’t plan on leaving the app deployed, it barely costs anything.
This isn’t a tutorial on Clojure, so I’ll assume you already have some familiarity with the language as well as some of its libraries. [2] [2]
In this post, we’ll be building a barebones bookmarks manager for the demo app. Users can log in using basic authentication, view all bookmarks, and create a new bookmark. It’ll be a traditional multi-page web app and the data will be stored in a SQLite database.
Here’s an overview of the project’s starting directory structure:
.
├── dev
│ └── user.clj
├── resources
│ └── config.edn
├── src
│ └── acme
│ └── main.clj
└── deps.edn
And the libraries we’re going to use. If you have some Clojure experience or have used Kit, you’re probably already familiar with all the libraries listed below. [3] [3]
{:paths ["src" "resources"]
:deps {org.clojure/clojure {:mvn/version "1.12.0"}
aero/aero {:mvn/version "1.1.6"}
integrant/integrant {:mvn/version "0.11.0"}
ring/ring-jetty-adapter {:mvn/version "1.12.2"}
metosin/reitit-ring {:mvn/version "0.7.2"}
com.github.seancorfield/next.jdbc {:mvn/version "1.3.939"}
org.xerial/sqlite-jdbc {:mvn/version "3.46.1.0"}
hiccup/hiccup {:mvn/version "2.0.0-RC3"}}
:aliases
{:dev {:extra-paths ["dev"]
:extra-deps {nrepl/nrepl {:mvn/version "1.3.0"}
integrant/repl {:mvn/version "0.3.3"}}
:main-opts ["-m" "nrepl.cmdline" "--interactive" "--color"]}}}
I use Aero and Integrant for my system configuration (more on this in the next section), Ring with the Jetty adaptor for the web server, Reitit for routing, next.jdbc for database interaction, and Hiccup for rendering HTML. From what I’ve seen, this is a popular “library combination” for building web apps in Clojure. [4] [4]
The user namespace in dev/user.clj contains helper functions from Integrant-repl to start, stop, and restart the Integrant system.
(ns user
(:require
[acme.main :as main]
[clojure.tools.namespace.repl :as repl]
[integrant.core :as ig]
[integrant.repl :refer [set-prep! go halt reset reset-all]]))
(set-prep!
(fn []
(ig/expand (main/read-config)))) ;; we'll implement this soon
(repl/set-refresh-dirs "src" "resources")
(comment
(go)
(halt)
(reset)
(reset-all))
If you’re new to Integrant or other dependency injection libraries like Component, I’d suggest reading “How to Structure a Clojure Web”. It’s a great explanation of the reasoning behind these libraries. Like most Clojure apps that use Aero and Integrant, my system configuration lives in a .edn file. I usually name mine as resources/config.edn. Here’s what it looks like:
{:server
{:port #long #or [#env PORT 8080]
:host #or [#env HOST "0.0.0.0"]
:auth {:username #or [#env AUTH_USER "john.doe@email.com"]
:password #or [#env AUTH_PASSWORD "password"]}}
:database
{:dbtype "sqlite"
:dbname #or [#env DB_DATABASE "database.db"]}}
In production, most of these values will be set using environment variables. During local development, the app will use the hard-coded default values. We don’t have any sensitive values in our config (e.g., API keys), so it’s fine to commit this file to version control. If there are such values, I usually put them in another file that’s not tracked by version control and include them in the config file using Aero’s #include reader tag.
This config file is then “expanded” into the Integrant system map using the expand-key method:
(ns acme.main
(:require
[aero.core :as aero]
[clojure.java.io :as io]
[integrant.core :as ig]))
(defn read-config
[]
{:system/config (aero/read-config (io/resource "config.edn"))})
(defmethod ig/expand-key :system/config
[_ opts]
(let [{:keys [server database]} opts]
{:server/jetty (assoc server :handler (ig/ref :handler/ring))
:handler/ring {:database (ig/ref :database/sql)
:auth (:auth server)}
:database/sql database}))
The system map is created in code instead of being in the configuration file. This makes refactoring your system simpler as you only need to change this method while leaving the config file (mostly) untouched. [5] [5]
My current approach to Integrant + Aero config files is mostly inspired by the blog post “Rethinking Config with Aero & Integrant” and Laravel’s configuration. The config file follows a similar structure to Laravel’s config files and contains the app configurations without describing the structure of the system. Previously, I had a key for each Integrant component, which led to the config file being littered with #ig/ref and more difficult to refactor.
Also, if you haven’t already, start a REPL and connect to it from your editor. Run clj -M:dev if your editor doesn’t automatically start a REPL. Next, we’ll implement the init-key and halt-key! methods for each of the components:
;; src/acme/main.clj
(ns acme.main
(:require
;; ...
[acme.handler :as handler]
[acme.util :as util])
[next.jdbc :as jdbc]
[ring.adapter.jetty :as jetty]))
;; ...
(defmethod ig/init-key :server/jetty
[_ opts]
(let [{:keys [handler port]} opts
jetty-opts (-> opts (dissoc :handler :auth) (assoc :join? false))
server (jetty/run-jetty handler jetty-opts)]
(println "Server started on port " port)
server))
(defmethod ig/halt-key! :server/jetty
[_ server]
(.stop server))
(defmethod ig/init-key :handler/ring
[_ opts]
(handler/handler opts))
(defmethod ig/init-key :database/sql
[_ opts]
(let [datasource (jdbc/get-datasource opts)]
(util/setup-db datasource)
datasource))
The setup-db function creates the required tables in the database if they don’t exist yet. This works fine for database migrations in small projects like this demo app, but for larger projects, consider using libraries such as Migratus (my preferred library) or Ragtime.
(ns acme.util
(:require
[next.jdbc :as jdbc]))
(defn setup-db
[db]
(jdbc/execute-one!
db
["create table if not exists bookmarks (
bookmark_id text primary key not null,
url text not null,
created_at datetime default (unixepoch()) not null
)"]))
For the server handler, let’s start with a simple function that returns a “hi world” string.
(ns acme.handler
(:require
[ring.util.response :as res]))
(defn handler
[_opts]
(fn [req]
(res/response "hi world")))
Now all the components are implemented. We can check if the system is working properly by evaluating (reset) in the user namespace. This will reload your files and restart the system. You should see this message printed in your REPL:
:reloading (acme.util acme.handler acme.main)
Server started on port 8080
:resumed
If we send a request to http://localhost:8080/, we should get “hi world” as the response:
$ curl localhost:8080/
# hi world
Nice! The system is working correctly. In the next section, we’ll implement routing and our business logic handlers.
First, let’s set up a ring handler and router using Reitit. We only have one route, the index / route that’ll handle both GET and POST requests.
(ns acme.handler
(:require
[reitit.ring :as ring]))
(def routes
[["/" {:get index-page
:post index-action}]])
(defn handler
[opts]
(ring/ring-handler
(ring/router routes)
(ring/routes
(ring/redirect-trailing-slash-handler)
(ring/create-resource-handler {:path "/"})
(ring/create-default-handler))))
We’re including some useful middleware:
redirect-trailing-slash-handler to resolve routes with trailing slashes,create-resource-handler to serve static files, andcreate-default-handler to handle common 40x responses.If you remember the :handler/ring from earlier, you’ll notice that it has two dependencies, database and auth. Currently, they’re inaccessible to our route handlers. To fix this, we can inject these components into the Ring request map using a middleware function.
;; ...
(defn components-middleware
[components]
(let [{:keys [database auth]} components]
(fn [handler]
(fn [req]
(handler (assoc req
:db database
:auth auth))))))
;; ...
The components-middleware function takes in a map of components and creates a middleware function that “assocs” each component into the request map.
[6]
[6]
If you have more components such as a Redis cache or a mail service, you can add them here.
We’ll also need a middleware to handle HTTP basic authentication.
[7]
[7]
This middleware will check if the username and password from the request map match the values in the auth map injected by components-middleware. If they match, then the request is authenticated and the user can view the site.
(ns acme.handler
(:require
;; ...
[acme.util :as util]
[ring.util.response :as res]))
;; ...
(defn wrap-basic-auth
[handler]
(fn [req]
(let [{:keys [headers auth]} req
{:keys [username password]} auth
authorization (get headers "authorization")
correct-creds (str "Basic " (util/base64-encode
(format "%s:%s" username password)))]
(if (and authorization (= correct-creds authorization))
(handler req)
(-> (res/response "Access Denied")
(res/status 401)
(res/header "WWW-Authenticate" "Basic realm=protected"))))))
;; ...
A nice feature of Clojure is that interop with the host language is easy. The base64-encode function is just a thin wrapper over Java’s Base64.Encoder:
(ns acme.util
;; ...
(:import java.util.Base64))
(defn base64-encode
[s]
(.encodeToString (Base64/getEncoder) (.getBytes s)))
Finally, we need to add them to the router. Since we’ll be handling form requests later, we’ll also bring in Ring’s wrap-params middleware.
(ns acme.handler
(:require
;; ...
[ring.middleware.params :refer [wrap-params]]))
;; ...
(defn handler
[opts]
(ring/ring-handler
;; ...
{:middleware [(components-middleware opts)
wrap-basic-auth
wrap-params]}))
We now have everything we need to implement the route handlers or the business logic of the app. First, we’ll implement the index-page function, which renders a page that:
(ns acme.handler
(:require
;; ...
[next.jdbc :as jdbc]
[next.jdbc.sql :as sql]))
;; ...
(defn template
[bookmarks]
[:html
[:head
[:meta {:charset "utf-8"
:name "viewport"
:content "width=device-width, initial-scale=1.0"}]]
[:body
[:h1 "bookmarks"]
[:form {:method "POST"}
[:div
[:label {:for "url"} "url "]
[:input#url {:name "url"
:type "url"
:required true
:placeholer "https://en.wikipedia.org/"}]]
[:button "submit"]]
[:p "your bookmarks:"]
[:ul
(if (empty? bookmarks)
[:li "you don't have any bookmarks"]
(map
(fn [{:keys [url]}]
[:li
[:a {:href url} url]])
bookmarks))]]])
(defn index-page
[req]
(try
(let [bookmarks (sql/query (:db req)
["select * from bookmarks"]
jdbc/unqualified-snake-kebab-opts)]
(util/render (template bookmarks)))
(catch Exception e
(util/server-error e))))
;; ...
Database queries can sometimes throw exceptions, so it’s good to wrap them in a try-catch block. I’ll also introduce some helper functions:
(ns acme.util
(:require
;; ...
[hiccup2.core :as h]
[ring.util.response :as res])
(:import java.util.Base64))
;; ...
(defn preprend-doctype
[s]
(str "<!doctype html>" s))
(defn render
[hiccup]
(-> hiccup h/html str preprend-doctype res/response (res/content-type "text/html")))
(defn server-error
[e]
(println "Caught exception: " e)
(-> (res/response "Internal server error")
(res/status 500)))
render takes a hiccup form and turns it into a ring response, while server-error takes an exception, logs it, and returns a 500 response.
Next, we’ll implement the index-action function:
;; ...
(defn index-action
[req]
(try
(let [{:keys [db form-params]} req
value (get form-params "url")]
(sql/insert! db :bookmarks {:bookmark_id (random-uuid) :url value})
(res/redirect "/" 303))
(catch Exception e
(util/server-error e))))
;; ...
This is an implementation of a typical post/redirect/get pattern. We get the value from the URL form field, insert a new row in the database with that value, and redirect back to the index page. Again, we’re using a try-catch block to handle possible exceptions from the database query.
That should be all of the code for the controllers. If you reload your REPL and go to http://localhost:8080, you should see something that looks like this after logging in:

The last thing we need to do is to update the main function to start the system:
;; ...
(defn -main [& _]
(-> (read-config) ig/expand ig/init))
Now, you should be able to run the app using clj -M -m acme.main. That’s all the code needed for the app. In the next section, we’ll package the app into a Docker image to deploy to Fly.
While there are many ways to package a Clojure app, Fly.io specifically requires a Docker image. There are two approaches to doing this:
Both are valid approaches. I prefer the first since its only dependency is the JVM. We’ll use the tools.build library to build the uberjar. Check out the official guide for more information on building Clojure programs. Since it’s a library, to use it, we can add it to our deps.edn file with an alias:
{;; ...
:aliases
{;; ...
:build {:extra-deps {io.github.clojure/tools.build
{:git/tag "v0.10.5" :git/sha "2a21b7a"}}
:ns-default build}}}
Tools.build expects a build.clj file in the root of the project directory, so we’ll need to create that file. This file contains the instructions to build artefacts, which in our case is a single uberjar. There are many great examples of build.clj files on the web, including from the official documentation. For now, you can copy+paste this file into your project.
(ns build
(:require
[clojure.tools.build.api :as b]))
(def basis (delay (b/create-basis {:project "deps.edn"})))
(def src-dirs ["src" "resources"])
(def class-dir "target/classes")
(defn uber
[_]
(println "Cleaning build directory...")
(b/delete {:path "target"})
(println "Copying files...")
(b/copy-dir {:src-dirs src-dirs
:target-dir class-dir})
(println "Compiling Clojure...")
(b/compile-clj {:basis @basis
:ns-compile '[acme.main]
:class-dir class-dir})
(println "Building Uberjar...")
(b/uber {:basis @basis
:class-dir class-dir
:uber-file "target/standalone.jar"
:main 'acme.main}))
To build the project, run clj -T:build uber. This will create the uberjar standalone.jar in the target directory. The uber in clj -T:build uber refers to the uber function from build.clj. Since the build system is a Clojure program, you can customise it however you like. If we try to run the uberjar now, we’ll get an error:
# build the uberjar
$ clj -T:build uber
# Cleaning build directory...
# Copying files...
# Compiling Clojure...
# Building Uberjar...
# run the uberjar
$ java -jar target/standalone.jar
# Error: Could not find or load main class acme.main
# Caused by: java.lang.ClassNotFoundException: acme.main
This error occurred because the Main class that is required by Java isn’t built. To fix this, we need to add the :gen-class directive in our main namespace. This will instruct Clojure to create the Main class from the -main function.
(ns acme.main
;; ...
(:gen-class))
;; ...
If you rebuild the project and run java -jar target/standalone.jar again, it should work perfectly. Now that we have a working build script, we can write the Dockerfile:
# install additional dependencies here in the base layer
# separate base from build layer so any additional deps installed are cached
FROM clojure:temurin-21-tools-deps-bookworm-slim AS base
FROM base AS build
WORKDIR /opt
COPY . .
RUN clj -T:build uber
FROM eclipse-temurin:21-alpine AS prod
COPY /opt/target/standalone.jar /
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "standalone.jar"]
It’s a multi-stage Dockerfile. We use the official Clojure Docker image as the layer to build the uberjar. Once it’s built, we copy it to a smaller Docker image that only contains the Java runtime. [8] [8] By doing this, we get a smaller container image as well as a faster Docker build time because the layers are better cached.
That should be all for packaging the app. We can move on to the deployment now.
First things first, you’ll need to install flyctl, Fly’s CLI tool for interacting with their platform. Create a Fly.io account if you haven’t already. Then run fly auth login to authenticate flyctl with your account.
Next, we’ll need to create a new Fly App:
$ fly app create
# ? Choose an app name (leave blank to generate one):
# automatically selected personal organization: Ryan Martin
# New app created: blue-water-6489
Another way to do this is with the fly launch command, which automates a lot of the app configuration for you. We have some steps to do that are not done by fly launch, so we’ll be configuring the app manually. I also already have a fly.toml file ready that you can straight away copy to your project.
# replace these with your app and region name
# run `fly platform regions` to get a list of regions
app = 'blue-water-6489'
primary_region = 'sin'
[env]
DB_DATABASE = "/data/database.db"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true
min_machines_running = 0
[mounts]
source = "data"
destination = "/data"
initial_sie = 1
[[vm]]
size = "shared-cpu-1x"
memory = "512mb"
cpus = 1
cpu_kind = "shared"
These are mostly the default configuration values with some additions. Under the [env] section, we’re setting the SQLite database location to /data/database.db. The database.db file itself will be stored in a persistent Fly Volume mounted on the /data directory. This is specified under the [mounts] section. Fly Volumes are similar to regular Docker volumes but are designed for Fly’s micro VMs.
We’ll need to set the AUTH_USER and AUTH_PASSWORD environment variables too, but not through the fly.toml file as these are sensitive values. To securely set these credentials with Fly, we can set them as app secrets. They’re stored encrypted and will be automatically injected into the app at boot time.
$ fly secrets set AUTH_USER=hi@ryanmartin.me AUTH_PASSWORD=not-so-secure-password
# Secrets are staged for the first deployment
With this, the configuration is done and we can deploy the app using fly deploy:
$ fly deploy
# ...
# Checking DNS configuration for blue-water-6489.fly.dev
# Visit your newly deployed app at https://blue-water-6489.fly.dev/
The first deployment will take longer since it’s building the Docker image for the first time. Subsequent deployments should be faster due to the cached image layers. You can click on the link to view the deployed app, or you can also run fly open, which will do the same thing. Here’s the app in action:

If you made additional changes to the app or fly.toml, you can redeploy the app using the same command, fly deploy. The app is configured to auto stop/start, which helps to cut costs when there’s not a lot of traffic to the site. If you want to take down the deployment, you’ll need to delete the app itself using fly app destroy <your app name>.
This is an interesting topic in the Clojure community, with varying opinions on whether or not it’s a good idea. Personally, I find having a REPL connected to the live app helpful, and I often use it for debugging and running queries on the live database. [9] [9] Since we’re using SQLite, we don’t have a database server we can directly connect to, unlike Postgres or MySQL.
If you’re brave, you can even restart the app directly without redeploying from the REPL. You can easily go wrong with it, which is why some prefer not to use it.
For this project, we’re gonna add a socket REPL. It’s very simple to add (you just need to add a JVM option) and it doesn’t require additional dependencies like nREPL. Let’s update the Dockerfile:
# ...
EXPOSE 7888
ENTRYPOINT ["java", "-Dclojure.server.repl={:port 7888 :accept clojure.core.server/repl}", "-jar", "standalone.jar"]
The socket REPL will be listening on port 7888. If we redeploy the app now, the REPL will be started, but we won’t be able to connect to it. That’s because we haven’t exposed the service through Fly proxy. We can do this by adding the socket REPL as a service in the [services] section in fly.toml.
However, doing this will also expose the REPL port to the public. This means that anyone can connect to your REPL and possibly mess with your app. Instead, what we want to do is to configure the socket REPL as a private service.
By default, all Fly apps in your organisation live in the same private network. This private network, called 6PN, connects the apps in your organisation through WireGuard tunnels (a VPN) using IPv6. Fly private services aren’t exposed to the public internet but can be reached from this private network. We can then use Wireguard to connect to this private network to reach our socket REPL.
Fly VMs are also configured with the hostname fly-local-6pn, which maps to its 6PN address. This is analogous to localhost, which points to your loopback address 127.0.0.1. To expose a service to 6PN, all we have to do is bind or serve it to fly-local-6pn instead of the usual 0.0.0.0. We have to update the socket REPL options to:
# ...
ENTRYPOINT ["java", "-Dclojure.server.repl={:port 7888,:address \"fly-local-6pn\",:accept clojure.core.server/repl}", "-jar", "standalone.jar"]
After redeploying, we can use the fly proxy command to forward the port from the remote server to our local machine.
[10]
[10]
$ fly proxy 7888:7888
# Proxying local port 7888 to remote [blue-water-6489.internal]:7888
In another shell, run:
$ rlwrap nc localhost 7888
# user=>
Now we have a REPL connected to the production app! rlwrap is used for readline functionality, e.g. up/down arrow keys, vi bindings. Of course, you can also connect to it from your editor.
If you’re using GitHub, we can also set up automatic deployments on pushes/PRs with GitHub Actions. All you need is to create the workflow file:
name: Fly Deploy
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
name: Deploy app
runs-on: ubuntu-latest
concurrency: deploy-group
steps:
- uses: actions/checkout@v4
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
To get this to work, you’ll need to create a deploy token from your app’s dashboard. Then, in your GitHub repo, create a new repository secret called FLY_API_TOKEN with the value of your deploy token. Now, whenever you push to the main branch, this workflow will automatically run and deploy your app. You can also manually run the workflow from GitHub because of the workflow_dispatch option.
As always, all the code is available on GitHub. Originally, this post was just about deploying to Fly.io, but along the way, I kept adding on more stuff until it essentially became my version of the user manager example app. Anyway, hope this post provided a good view into web development with Clojure. As a bonus, here are some additional resources on deploying Clojure apps:
The way Fly.io works under the hood is pretty clever. Instead of running the container image with a runtime like Docker, the image is unpacked and “loaded” into a VM. See this video explanation for more details. ↩︎
If you’re interested in learning Clojure, my recommendation is to follow the official getting started guide and join the Clojurians Slack. Also, read through this list of introductory resources. ↩︎
Kit was a big influence on me when I first started learning web development in Clojure. I never used it directly, but I did use their library choices and project structure as a base for my own projects. ↩︎
There’s no “Rails” for the Clojure ecosystem (yet?). The prevailing opinion is to build your own “framework” by composing different libraries together. Most of these libraries are stable and are already used in production by big companies, so don’t let this discourage you from doing web development in Clojure! ↩︎
There might be some keys that you add or remove, but the structure of the config file stays the same. ↩︎
“assoc” (associate) is a Clojure slang that means to add or update a key-value pair in a map. ↩︎
For more details on how basic authentication works, check out the specification. ↩︎
Here’s a cool resource I found when researching Java Dockerfiles: WhichJDK. It provides a comprehensive comparison of the different JDKs available and recommendations on which one you should use. ↩︎
Another (non-technically important) argument for live/production REPLs is just because it’s cool. Ever since I read the story about NASA’s programmers debugging a spacecraft through a live REPL, I’ve always wanted to try it at least once. ↩︎
If you encounter errors related to WireGuard when running fly proxy, you can run fly doctor, which will hopefully detect issues with your local setup and also suggest fixes for them. ↩︎
This post is about six seven months late, but here are my takeaways from Advent of Code 2024. It was my second time participating, and this time I actually managed to complete it.
[1]
[1]
My goal was to learn a new language, Zig, and to improve my DSA and problem-solving skills.
If you’re not familiar, Advent of Code is an annual programming challenge that runs every December. A new puzzle is released each day from December 1st to the 25th. There’s also a global leaderboard where people (and AI) race to get the fastest solves, but I personally don’t compete in it, mostly because I want to do it at my own pace.
I went with Zig because I have been curious about it for a while, mainly because of its promise of being a better C and because TigerBeetle (one of the coolest databases now) is written in it. Learning Zig felt like a good way to get back into systems programming, something I’ve been wanting to do after a couple of chaotic years of web development.
This post is mostly about my setup, results, and the things I learned from solving the puzzles. If you’re more interested in my solutions, I’ve also uploaded my code and solution write-ups to my GitHub repository.

There were several Advent of Code templates in Zig that I looked at as a reference for my development setup, but none of them really clicked with me. I ended up just running my solutions directly using zig run for the whole event. It wasn’t until after the event ended that I properly learned Zig’s build system and reorganised my project.
Here’s what the project structure looks like now:
.
├── src
│ ├── days
│ │ ├── data
│ │ │ ├── day01.txt
│ │ │ ├── day02.txt
│ │ │ └── ...
│ │ ├── day01.zig
│ │ ├── day02.zig
│ │ └── ...
│ ├── bench.zig
│ └── run.zig
└── build.zig
The project is powered by build.zig, which defines several commands:
zig build - Builds all of the binaries for all optimisation modes.zig build run - Runs all solutions sequentially.zig build run -Day=XX - Runs the solution of the specified day only.zig build bench - Runs all benchmarks sequentially.zig build bench -Day=XX - Runs the benchmark of the specified day only.zig build test - Runs all tests sequentially.zig build test -Day=XX - Runs the tests of the specified day only.You can also pass the optimisation mode that you want to any of the commands above with the -Doptimize flag.
Under the hood, build.zig compiles src/run.zig when you call zig build run, and src/bench.zig when you call zig build bench. These files are templates that import the solution for a specific day from src/days/dayXX.zig. For example, here’s what src/run.zig looks like:
const std = @import("std");
const puzzle = @import("day"); // Injected by build.zig
pub fn main() !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();
std.debug.print("{s}\n", .{puzzle.title});
_ = try puzzle.run(allocator, true);
std.debug.print("\n", .{});
}
The day module imported is an anonymous import dynamically injected by build.zig during compilation. This allows a single run.zig or bench.zig to be reused for all solutions. This avoids repeating boilerplate code in the solution files. Here’s a simplified version of my build.zig file that shows how this works:
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const run_all = b.step("run", "Run all days");
const day_option = b.option(usize, "ay", ""); // The `-Day` option
// Generate build targets for all 25 days.
for (1..26) |day| {
const day_zig_file = b.path(b.fmt("src/days/day{d:0>2}.zig", .{day}));
// Create an executable for running this specific day.
const run_exe = b.addExecutable(.{
.name = b.fmt("run-day{d:0>2}", .{day}),
.root_source_file = b.path("src/run.zig"),
.target = target,
.optimize = optimize,
});
// Inject the day-specific solution file as the anonymous module `day`.
run_exe.root_module.addAnonymousImport("day", .{ .root_source_file = day_zig_file });
// Install the executable so it can be run.
b.installArtifact(run_exe);
// ...
}
}
My actual build.zig has some extra code that builds the binaries for all optimisation modes.
This setup is pretty barebones. I’ve seen other templates do cool things like scaffold files, download puzzle inputs, and even submit answers automatically. Since I wrote my build.zig after the event ended, I didn’t get to use it while solving the puzzles. I might add these features to it if I decided to do Advent of Code again this year with Zig.
While there are no rules to Advent of Code itself, to make things a little more interesting, I set a few constraints and rules for myself:
@embedFile.Most of these constraints are designed to push me to write clearer, more performant code. I also wanted my code to look like it was taken straight from TigerBeetle’s codebase (minus the assertions). [3] [3] Lastly, I just thought it would make the experience more fun.
From all of the puzzles, here are my top 3 favourites:
Honourable mention:
During the event, I learned a lot about Zig and performance, and also developed some personal coding conventions. Some of these are Zig-specific, but most are universal and can be applied across languages. This section covers general programming and Zig patterns I found useful. The next section will focus on performance-related tips.
Zig’s flagship feature, comptime, is surprisingly useful. I knew Zig uses it for generics and that people do clever metaprogramming with it, but I didn’t expect to be using it so often myself.
My main use for comptime was to generate puzzle-specific types. All my solution files follow the same structure, with a DayXX function that takes some parameters (usually the input length) and returns a puzzle-specific type, e.g.:
fn Day01(comptime length: usize) type {
return struct {
const Self = @This();
left: [length]u32 = undefined,
right: [length]u32 = undefined,
fn init(input: []const u8) !Self {}
// ...
};
}
This lets me instantiate the type with a size that matches my input:
// Here, `Day01` is called with the size of my actual input.
pub fn run(_: std.mem.Allocator, is_run: bool) ![3]u64 {
// ...
const input = @embedFile("./data/day01.txt");
var puzzle = try Day01(1000).init(input);
// ...
}
// Here, `Day01` is called with the size of my test input.
test "day 01 part 1 sample 1" {
var puzzle = try Day01(6).init(sample_input);
// ...
}
This allows me to reuse logic across different inputs while still hardcoding the array sizes. Without comptime, I have to either create a separate function for all my different inputs or dynamically allocate memory because I can’t hardcode the array size.
I also used comptime to shift some computation to compile-time to reduce runtime overhead. For example, on day 4, I needed a function to check whether a string matches either "XMAS" or its reverse, "SAMX". A pretty simple function that you can write as a one-liner in Python:
def matches(pattern, target):
return target == pattern or target == pattern[::-1]
Typically, a function like this requires some dynamic allocation to create the reversed string, since the length of the string is only known at runtime. [4] [4] For this puzzle, since the words to reverse are known at compile-time, we can do something like this:
fn matches(comptime word: []const u8, slice: []const u8) bool {
var reversed: [word.len]u8 = undefined;
@memcpy(&reversed, word);
std.mem.reverse(u8, &reversed);
return std.mem.eql(u8, word, slice) or std.mem.eql(u8, &reversed, slice);
}
This creates a separate function for each word I want to reverse. [5] [5] Each function has an array with the same size as the word to reverse. This removes the need for dynamic allocation and makes the code run faster. As a bonus, Zig also warns you when this word isn’t compile-time known, so you get an immediate error if you pass in a runtime value.
A common pattern in C is to return special sentinel values to denote missing values or errors, e.g. -1, 0, or NULL. In fact, I did this on day 13 of the challenge:
// We won't ever get 0 as a result, so we use it as a sentinel error value.
fn count_tokens(a: [2]u8, b: [2]u8, p: [2]i64) u64 {
const numerator = @abs(p[0] * b[1] - p[1] * b[0]);
const denumerator = @abs(@as(i32, a[0]) * b[1] - @as(i32, a[1]) * b[0]);
return if (numerator % denumerator != 0) 0 else numerator / denumerator;
}
// Then in the caller, skip if the return value is 0.
if (count_tokens(a, b, p) == 0) continue;
This works, but it’s easy to forget to check for those values, or worse, to accidentally treat them as valid results. Zig improves on this with optional types. If a function might not return a value, you can return ?T instead of T. This also forces the caller to handle the null case. Unlike C, null isn’t a pointer but a more general concept. Zig treats null as the absence of a value for any type, just like Rust’s Option<T>.
The count_tokens function can be refactored to:
// Return null instead if there's no valid result.
fn count_tokens(a: [2]u8, b: [2]u8, p: [2]i64) ?u64 {
const numerator = @abs(p[0] * b[1] - p[1] * b[0]);
const denumerator = @abs(@as(i32, a[0]) * b[1] - @as(i32, a[1]) * b[0]);
return if (numerator % denumerator != 0) null else numerator / denumerator;
}
// The caller is now forced to handle the null case.
if (count_tokens(a, b, p)) |n_tokens| {
// logic only runs when n_tokens is not null.
}
Zig also has a concept of error unions, where a function can return either a value or an error. In Rust, this is Result<T>. You could also use error unions instead of optionals for count_tokens; Zig doesn’t force a single approach. I come from Clojure, where returning nil for an error or missing value is common.
This year has a lot of 2D grid puzzles (arguably too many). A common feature of grid-based algorithms is the out-of-bounds check. Here’s what it usually looks like:
fn dfs(map: [][]u8, position: [2]i8) u32 {
const x, const y = position;
// Bounds check here.
if (x < 0 or y < 0 or x >= map.len or y >= map[0].len) return 0;
if (map[x][y] == .visited) return 0;
map[x][y] = .visited;
var result: u32 = 1;
for (directions) | direction| {
result += dfs(map, position + direction);
}
return result;
}
This is a typical recursive DFS function. After doing a lot of this, I discovered a nice trick that not only improves code readability, but also its performance. The trick here is to pad the grid with sentinel characters that mark out-of-bounds areas, i.e. add a border to the grid.
Here’s an example from day 6:
Original map: With borders added:
************
....#..... *....#.....*
.........# *.........#*
.......... *..........*
..#....... *..#.......*
.......#.. -> *.......#..*
.......... *..........*
.#..^..... *.#..^.....*
........#. *........#.*
#......... *#.........*
......#... *......#...*
************
You can use any value for the border, as long as it doesn’t conflict with valid values in the grid. With the border in place, the bounds check becomes a simple equality comparison:
const border = '*';
fn dfs(map: [][]u8, position: [2]i8) u32 {
const x, const y = position;
if (map[x][y] == border) { // We are out of bounds
return 0;
}
// ...
}
This is much more readable than the previous code. Plus, it’s also faster since we’re only doing one equality check instead of four range checks.
That said, this isn’t a one-size-fits-all solution. This only works for algorithms that traverse the grid one step at a time. If your logic jumps multiple tiles, it can still go out of bounds (except if you increase the width of the border to account for this). This approach also uses a bit more memory than the regular approach as you have to store more characters.
This could also go in the performance section, but I’m including it here because the biggest benefit I get from using SIMD in Zig is the improved code readability. Because Zig has first-class support for vector types, you can write elegant and readable code that also happens to be faster.
If you’re not familiar with vectors, they are a special collection type used for Single instruction, multiple data (SIMD) operations. SIMD allows you to perform computation on multiple values in parallel using only a single CPU instruction, which often leads to some performance boosts. [6] [6]
I mostly use vectors to represent positions and directions, e.g. for traversing a grid. Instead of writing code like this:
next_position = .{ position[0] + direction[0], position[1] + direction[1] };
You can represent position and direction as 2-element vectors and write code like this:
next_position = position + direction;
This is much nicer than the previous version!
Day 25 is another good example of a problem that can be solved elegantly using vectors:
var result: u64 = 0;
for (self.locks.items) |lock| { // lock is a vector
for (self.keys.items) |key| { // key is also a vector
const fitted = lock + key > @as(@Vector(5, u8), @splat(5));
const is_overlap = @reduce(.Or, fitted);
result += @intFromBool(!is_overlap);
}
}
Expressing the logic as vector operations makes the code cleaner since you don’t have to write loops and conditionals as you typically would in a traditional approach.
The tips below are general performance techniques that often help, but like most things in software engineering, “it depends”. These might work 80% of the time, but performance is often highly context-specific. You should benchmark your code instead of blindly following what other people say.
This section would’ve been more fun with concrete examples, step-by-step optimisations, and benchmarks, but that would’ve made the post way too long. Hopefully, I’ll get to write something like that in the future. [7] [7]
Whenever possible, prefer static allocation. Static allocation is cheaper since it just involves moving the stack pointer vs dynamic allocation which has more overhead from the allocator machinery. That said, it’s not always the right choice since it has some limitations, e.g. stack size is limited, memory size must be compile-time known, its lifetime is tied to the current stack frame, etc.
If you need to do dynamic allocations, try to reduce the number of times you call the allocator. The number of allocations you do matters more than the amount of memory you allocate. More allocations mean more bookkeeping, synchronisation, and sometimes syscalls.
A simple but effective way to reduce allocations is to reuse buffers, whether they’re statically or dynamically allocated. Here’s an example from day 10. For each trail head, we want to create a set of trail ends reachable from it. The naive approach is to allocate a new set every iteration:
for (self.trail_heads.items) |trail_head| {
var trail_ends = std.AutoHashMap([2]u8, void).init(self.allocator);
defer trail_ends.deinit();
// Set building logic...
}
What you can do instead is to allocate the set once before the loop. Then, each iteration, you reuse the set by emptying it without freeing the memory. For Zig’s std.AutoHashMap, this can be done using the clearRetainingCapacity method:
var trail_ends = std.AutoHashMap([2]u8, void).init(self.allocator);
defer trail_ends.deinit();
for (self.trail_heads.items) |trail_head| {
trail_ends.clearRetainingCapacity();
// Set building logic...
}
If you use static arrays, you can also just overwrite existing data instead of clearing it.
A step up from this is to reuse multiple buffers. The simplest form of this is to reuse two buffers, i.e. double buffering. Here’s an example from day 11:
// Initialise two hash maps that we'll alternate between.
var frequencies: [2]std.AutoHashMap(u64, u64) = undefined;
for (0..2) |i| frequencies[i] = std.AutoHashMap(u64, u64).init(self.allocator);
defer for (0..2) |i| frequencies[i].deinit();
var id: usize = 0;
for (self.stones) |stone| try frequencies[id].put(stone, 1);
for (0..n_blinks) |_| {
var old_frequencies = &frequencies[id % 2];
var new_frequencies = &frequencies[(id + 1) % 2];
id += 1;
defer old_frequencies.clearRetainingCapacity();
// Do stuff with both maps...
}
Here we have two maps to count the frequencies of stones across iterations. Each iteration will build up new_frequencies with the values from old_frequencies. Doing this reduces the number of allocations to just 2 (the number of buffers). The tradeoff here is that it makes the code slightly more complex.
A performance tip people say is to have “mechanical sympathy”. Understand how your code is processed by your computer. An example of this is to structure your data so it works better with your CPU. For example, keep related data close in memory to take advantage of cache locality.
Reducing the size of your data helps with this. Smaller data means more of it can fit in cache. One way to shrink your data is through bit packing. This depends heavily on your specific data, so you’ll need to use your judgement to tell whether this would work for you. I’ll just share some examples that worked for me.
The first example is in day 6 part two, where you have to detect a loop, which happens when you revisit a tile from the same direction as before. To track this, you could use a map or a set to store the tiles and visited directions. A more efficient option is to store this direction metadata in the tile itself.
There are only four tile types, which means you only need two bits to represent the tile types as an enum. If the enum size is one byte, here’s what the tiles look like in memory:
.obstacle -> 00000000
.path -> 00000001
.visited -> 00000010
.path -> 00000011
As you can see, the upper six bits are unused. We can store the direction metadata in the upper four bits. One bit for each direction. If a bit is set, it means that we’ve already visited the tile in this direction. Here’s an illustration of the memory layout:
direction metadata tile type
┌─────┴─────┐ ┌─────┴─────┐
┌────────┬─┴─┬───┬───┬─┴─┬─┴─┬───┬───┬─┴─┐
│ Tile: │ 1 │ 0 │ 0 │ 0 │ 0 │ 0 │ 1 │ 0 │
└────────┴─┬─┴─┬─┴─┬─┴─┬─┴───┴───┴───┴───┘
up bit ─┘ │ │ └─ left bit
right bit ─┘ down bit
If your language supports struct packing, you can express this layout directly: [8] [8]
const Tile = packed struct(u8) {
const TileType = enum(u4) { obstacle, path, visited, exit };
up: u1 = 0,
right: u1 = 0,
down: u1 = 0,
left: u1 = 0,
tile: TileType,
// ...
}
Doing this avoids extra allocations and improves cache locality. Since the directions metadata is colocated with the tile type, all of them can fit together in cache. Accessing the directions just requires some bitwise operations instead of having to fetch them from another region of memory.
Another way to do this is to represent your data using alternate number bases. Here’s an example from day 23. Computers are represented as two-character strings made up of only lowercase letters, e.g. "bc", "xy", etc. Instead of storing this as a [2]u8 array, you can convert it into a base-26 number and store it as a u16.
[9]
[9]
Here’s the idea: map 'a' to 0, 'b' to 1, up to 'z' as 25. Each character in the string becomes a digit in the base-26 number. For example, "bc" ( [2]u8{ 'b', 'c' }) becomes the base-10 number 28 (). If we represent this using the base-64 character set, it becomes 12 ('b' = 1, 'c' = 2).
While they take the same amount of space (2 bytes), a u16 has some benefits over a [2]u8:
I won’t explain branchless programming here; Algorithmica explains it way better than I can. While modern compilers are often smart enough to compile away branches, they don’t catch everything. I still recommend writing branchless code whenever it makes sense. It also has the added benefit of reducing the number of codepaths in your program.
Again, since performance is very context-dependent, I’ll just show you some patterns I use. Here’s one that comes up often:
if (is_valid_report(report)) {
result += 1;
}
Instead of the branch, cast the bool into an integer directly:
result += @intFromBool(is_valid_report(report))
Another example is from day 6 (again!). Recall that to know if a tile has been visited from a certain direction, we have to check its direction bit. Here’s one way to do it:
fn has_visited(tile: Tile, direction: Direction) bool {
switch (direction) {
.up => return self.up == 1,
.right => return self.right == 1,
.down => return self.down == 1,
.left => return self.left == 1,
}
}
This works, but it introduces a few branches. We can make it branchless using bitwise operations:
fn has_visited(tile: Tile, direction: Direction) bool {
const int_tile = std.mem.nativeToBig(u8, @bitCast(tile));
const mask = direction.mask();
const bits = int_tile & 0xff; // Get only the direction bits
return bits & mask == mask;
}
While this is arguably cryptic and less readable, it does perform better than the switch version.
The final performance tip is to prefer iterative code over recursion. Recursive functions bring the overhead of allocating stack frames. While recursive code is more elegant, it’s also often slower unless your language’s compiler can optimise it away, e.g. via tail-call optimisation. As far as I know, Zig doesn’t have this, though I might be wrong.
Recursion also has the risk of causing a stack overflow if the execution isn’t bounded. This is why code that is mission- or safety-critical avoids recursion entirely. It’s in TigerBeetle’s TIGERSTYLE and also NASA’s Power of Ten.
Iterative code can be harder to write in some cases, e.g. DFS maps naturally to recursion, but most of the time it is significantly faster, more predictable, and safer than the recursive alternative.
I ran benchmarks for all 25 solutions in each of Zig’s optimisation modes. You can find the full results and the benchmark script in my GitHub repository. All benchmarks were done on an Apple M3 Pro.
As expected, ReleaseFast produced the best result with a total runtime of 85.1 ms. I’m quite happy with this, considering the two constraints that limited the number of optimisations I can do to the code:
You can see the full benchmarks for ReleaseFast in the table below:
| Day | Title | Parsing (µs) | Part 1 (µs) | Part 2 (µs) | Total (µs) |
|---|---|---|---|---|---|
| 1 | Historian Hysteria | 23.5 | 15.5 | 2.8 | 41.8 |
| 2 | Red-Nosed Reports | 42.9 | 0.0 | 11.5 | 54.4 |
| 3 | Mull it Over | 0.0 | 7.2 | 16.0 | 23.2 |
| 4 | Ceres Search | 5.9 | 0.0 | 0.0 | 5.9 |
| 5 | Print Queue | 22.3 | 0.0 | 4.6 | 26.9 |
| 6 | Guard Gallivant | 14.0 | 25.2 | 24,331.5 | 24,370.7 |
| 7 | Bridge Repair | 72.6 | 321.4 | 9,620.7 | 10,014.7 |
| 8 | Resonant Collinearity | 2.7 | 3.3 | 13.4 | 19.4 |
| 9 | Disk Fragmenter | 0.8 | 12.9 | 137.9 | 151.7 |
| 10 | Hoof It | 2.2 | 29.9 | 27.8 | 59.9 |
| 11 | Plutonian Pebbles | 0.1 | 43.8 | 2,115.2 | 2,159.1 |
| 12 | Garden Groups | 6.8 | 164.4 | 249.0 | 420.3 |
| 13 | Claw Contraption | 14.7 | 0.0 | 0.0 | 14.7 |
| 14 | Restroom Redoubt | 13.7 | 0.0 | 0.0 | 13.7 |
| 15 | Warehouse Woes | 14.6 | 228.5 | 458.3 | 701.5 |
| 16 | Reindeer Maze | 12.6 | 2,480.8 | 9,010.7 | 11,504.1 |
| 17 | Chronospatial Computer | 0.1 | 0.2 | 44.5 | 44.8 |
| 18 | RAM Run | 35.6 | 15.8 | 33.8 | 85.2 |
| 19 | Linen Layout | 10.7 | 11,890.8 | 11,908.7 | 23,810.2 |
| 20 | Race Condition | 48.7 | 54.5 | 54.2 | 157.4 |
| 21 | Keypad Conundrum | 0.0 | 1.7 | 22.4 | 24.2 |
| 22 | Monkey Market | 20.7 | 0.0 | 11,227.7 | 11,248.4 |
| 23 | LAN Party | 13.6 | 22.0 | 2.5 | 38.2 |
| 24 | Crossed Wires | 5.0 | 41.3 | 14.3 | 60.7 |
| 25 | Code Chronicle | 24.9 | 0.0 | 0.0 | 24.9 |
A weird thing I found when benchmarking is that for day 6 part two, ReleaseSafe actually ran faster than ReleaseFast (13,189.0 µs vs 24,370.7 µs). Their outputs are the same, but for some reason, ReleaseSafe is faster even with the safety checks still intact.
The Zig compiler is still very much a moving target, so I don’t want to dig too deep into this, as I’m guessing this might be a bug in the compiler. This weird behaviour might just disappear after a few compiler version updates.
Looking back, I’m really glad I decided to do Advent of Code and followed through to the end. I learned a lot of things. Some are useful in my professional work, some are more like random bits of trivia. Going with Zig was a good choice too. The language is small, simple, and gets out of your way. I learned more about algorithms and concepts than the language itself.
Besides what I’ve already mentioned earlier, here are some examples of the things I learned:
Some of my self-imposed constraints and rules ended up being helpful. I can still (mostly) understand the code I wrote a few months ago. Putting all of the code in a single file made it easier to read since I don’t have to context switch to other files all the time.
However, some of them did backfire a bit, e.g. the two constraints that limit how I can optimise my code. Another one is the “hardcoding allowed” rule. I used a lot of magic numbers, which helped to improve performance, but I didn’t document them, so after a while, I don’t even remember how I got them. I’ve since gone back and added explanations in my write-ups, but next time I’ll remember to at least leave comments.
One constraint I’ll probably remove next time is the no concurrency rule. It’s the biggest contributor to the total runtime of my solutions. I don’t do a lot of concurrent programming, even though my main language at work is Go, so next time it might be a good idea to use Advent of Code to level up my concurrency skills.
I also spent way more time on these puzzles than I originally expected. I optimised and rewrote my code multiple times. I also rewrote my write-ups a few times to make them easier to read. This is by far my longest side project yet. It’s a lot of fun, but it also takes a lot of time and effort. I almost gave up on the write-ups (and this blog post) because I don’t want to explain my awful day 15 and day 16 code. I ended up taking a break for a few months before finishing it, which is why this post is published in August lol.
Just for fun, here’s a photo of some of my notebook sketches that helped me visualise my solutions. See if you can guess which days these are from:

So… would I do it again? Probably, though I’m not making any promises. If I do join this year, I’ll probably stick with Zig. I had my eyes on Zig since the start of 2024, so Advent of Code was the perfect excuse to learn it. This year, there aren’t any languages in particular that caught my eye, so I’ll just keep using Zig, especially since I have a proper setup ready.
If you haven’t tried Advent of Code, I highly recommend checking it out this year. It’s a great excuse to learn a new language, improve your problem-solving skills, or just learn something new. If you’re eager, you can also do the previous years’ puzzles as they’re still available.
One of the best aspects of Advent of Code is the community. The Advent of Code subreddit is a great place for discussion. You can ask questions and also see other people’s solutions. Some people also post really cool visualisations like this one. They also have memes!
I failed my first attempt horribly with Clojure during Advent of Code 2023. Once I reached the later half of the event, I just couldn’t solve the problems with a purely functional style. I could’ve pushed through using imperative code, but I stubbornly chose not to and gave up… ↩︎
The original constraint was that each solution must run in under one second. As it turned out, the code was faster than I expected, so I increased the difficulty. ↩︎
TigerBeetle’s code quality and engineering principles are just wonderful. ↩︎
You can implement this function without any allocation by mutating the string in place or by iterating over it twice, which is probably faster than my current implementation. I kept it as-is as a reminder of what comptime can do. ↩︎
As a bonus, I was curious as to what this looks like compiled, so I listed all the functions in this binary in GDB and found:
72: static bool day04.Day04(140).matches__anon_19741;
72: static bool day04.Day04(140).matches__anon_19750;
It does generate separate functions! ↩︎
Well, not always. The number of SIMD instructions depends on the machine’s native SIMD size. If the length of the vector exceeds it, Zig will compile it into multiple SIMD instructions. ↩︎
Here’s a nice post on optimising day 9’s solution with Rust. It’s a good read if you’re into performance engineering or Rust techniques. ↩︎
One thing about packed structs is that their layout is dependent on the system endianness. Most modern systems are little-endian, so the memory layout I showed is actually reversed. Thankfully, Zig has some useful functions to convert between endianness like std.mem.nativeToBig, which makes working with packed structs easier. ↩︎
Technically, you can store 2-digit base 26 numbers in a u10, as there are only possible numbers. Most systems usually pad values by byte size, so u10 will still be stored as u16, which is why I just went straight for it. ↩︎
Code
In src/little_ring_things/template.clj
(ns little-ring-things.template)
(defn template [title body]
(str "<!DOCTYPE html>
<html>
<head>
<meta charset=\"UTF-8\">
<title>" title "</title>
</head>
<body>
" body "
</body>
</html>"))
In src/little_ring_things/handler.clj
(ns little-ring-things.handler
(:require [compojure.core :refer :all]
[compojure.route :as route]
[ring.middleware.defaults :refer [wrap-defaults site-defaults]]
[little-ring-things.template :as t])
(:import [java.time LocalDateTime]))
(defroutes app-routes
(GET "/" []
(t/template "Home" "<p>Hello World</p>"))
(GET "/about" []
(t/template "About" "<p>This is Clojure ring tutorial</p>"))
(GET "/hello/:name" [name]
(t/template "Hello" (str "<p>Hello " name "</p>")))
(GET "/time" []
(t/template "Time" (str "<p>The current time is " (LocalDateTime/now) "</p>")))
(route/not-found "Not Found"))
(def app
(wrap-defaults app-routes site-defaults))
Notes
Meta-programming = the broad idea of “programs that manipulate or generate programs”. It can happen at runtime (reflection) or compile-time (macros).
Macros = one specific style of meta-programming, usually tied to transforming syntax at compile time (in a pre-processor or AST-transformer). It takes a piece of code as input and replaces it with another piece of code as output, often based on patterns or parameters.
Here are some programming languages and their meta-programming and macro capabilities.
NB! Take with a grain of salt. The result comes from working with perplexity.ai, and I have not had a chance to personally verify all of the cells. They do look generally correct to me overall, though. Corrections are welcome!
Here are the programming languages with their scores (out of 18) and links to their repos or homepages:
Scores are out of 18 = 4 (metaprogramming) + 3 (compile‑time facilities) + 11 (macro features).
Each cell is either ✅ (yes) or – (no / limited).
| Feature / language | Racket | Scheme (R7RS‑small) | Common Lisp | Clojure | Rust | Nim | Zig comptime | Jai | C++ | Ruby | Carp |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Metaprogramming: | |||||||||||
| Runtime metaprogramming (e.g., open classes, define_method, method hooks) | – | – | – | – | – | – | – | – | – | ✅ | – |
| Runtime reflection / introspection | ✅ | ✅ | ✅ | ✅ | – | – | – | – | ✅ | ✅ | – |
| Runtime eval / dynamic code loading | ✅ | ✅ | ✅ | ✅ | – | – | – | – | – | ✅ | – |
| Build‑ or tooling‑level code generation supported | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Metaprogramming score (out of 4) | 4 | 4 | 4 | 3 | 1 | 1 | 1 | 1 | 2 | 3 | 1 |
| Compile‑time facilities (not strictly macros): | |||||||||||
| Run arbitrary code at compile time | ✅ | ✅ | ✅ | – | ✅ | ✅ | ✅ | ✅ | ✅ (constexpr) | – | ✅ |
| Types as values at compile time | ✅ (– but in Typed Racket) | – | – | – | ✅ | ✅ | ✅ | ✅ | ✅ (constexpr + templates) | – | – |
| constexpr‑style type‑level / compile‑time computation | ✅ | – | – | – | ✅ (const‑eval) | ✅ | ✅ | ✅ | ✅ (via constexpr) | – | ✅ |
| Compile‑time facilities score (out of 3) | 3 | 2 | 2 | 0 | 3 | 3 | 3 | 3 | 3 | 0 | 2 |
| Macros: | |||||||||||
| Hygienic identifier binding | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | – | ✅ | – | – | – |
| Operate on AST / syntax tree | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | – | – | – | – | – |
| Pattern‑based transformations | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | – | – | – | – | – |
| Define new syntactic forms | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | – | – | – | – | – |
| Define new keywords / syntax | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | – | – | – | – | – |
| Override core language forms | ✅ | ✅ | ✅ | – | – | – | – | – | – | – | – |
| Multi‑phase / macros of macros | ✅ | ✅ | ✅ | – | ✅ | – | – | – | – | – | – |
| Full‑fledged DSL / language building (via macros) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | – | – | – | – | – |
| Template metaprogramming | – | – | – | – | – | – | – | – | ✅ | – | – |
| Macro features score (out of 11) | 11 | 10 | 10 | 8 | 8 | 8 | 4 | 4 | 1 | 0 | 0 |
| Total score (out of 18) | 16 | 15 | 14 | 12 | 10 | 10 | 7 | 7 | 5 | 4 | 2 |
The score counts one point per row where the language can reasonably do what the feature describes (DSL‑building is counted as a full feature, even if “limited” in some languages).
The feature score is not an ultimate measure of meta-programming power, since a language (like C++) may have a higher score than another language (like Ruby), but generally be considered less tailored for meta-programming than the other language (Ruby is generally revered for its powerful meta-programming abilities).
Macro features are varied and many, and thus in the total score they gain an undue weight, although runtime meta-programming may be just as, or even more, powerful.
After years of watching talented developers bounce off Clojure’s prefix notation, we at Flexiana decided it was time to act. Today we’re open-sourcing **Infix** — a library that brings natural, readable mathematical and data-processing syntax to Clojure, while compiling to standard Clojure forms with zero runtime overhead.
This is not a toy. This is the future.
The Problem Nobody Was Brave Enough to Solve
Let’s be honest. We’ve all been in that meeting where a data scientist looks at
clojure
(+ (* a b) (/ c d))
and quietly opens a Python tab. We’ve all watched a business analyst try to read a pricing rule written as `(<= (count (:items order)) (* 2 (get-in config [:limits :base])))` and slowly lose the will to live.
Prefix notation is elegant. It is consistent. It is *theoretically* superior. But so is Esperanto, and we all know how that worked out.
The Solution
Infix lets you write this:
clojure
(infix a * b + c / d)
and it compiles to `(+ (* a b) (/ c d))`. Operator precedence works exactly as you’d expect from every other language you’ve ever used. Because we studied those languages. Carefully.
But we didn’t stop at arithmetic.
Threading as Infix Operators
`clojure
(infix users
->> (filter :active?)
->> (map :email)
->> (take 10))
Clojure’s threading macros become first-class infix operators with the lowest precedence, because data flows left to right. Like water. Like time. Like *progress*.
Arrow Lambdas
clojure
(map (infix x => x * x + 1) [1 2 3])
;; => [2 5 10]
Clean, readable anonymous functions. No `#(…)` gymnastics. No counting percent signs.
Function Definitions
clojure
(infix-defn calculate-discount [subtotal tier quantity]
(let [rate (cond (tier = :premium) 0.15
(quantity >= 10) 0.10
:else 0.05)]
(min (subtotal * rate) 100)))
Read that out loud. Show it to your product manager. Watch them nod instead of frown.
Early Returns
clojure
(infix-defn safe-divide [x y]
(when (= y 0) (return nil))
(/ x y))
Guard clauses. In Clojure. We went there.
Function Call Syntax
clojure
(infix max(3, 5) + min(1, 2)) ;; => 6
(infix Math/sqrt(9) * 2) ;; => 6.0
Familiar `fn(args)` notation, because sometimes you just want to feel at home.
How It Works
Everything is a macro. The `infix` macro uses a Shunting Yard parser to transform your expressions into standard Clojure forms at compile time. There is no interpreter. There is no string parsing. There is no runtime cost. Your production Clojure is exactly the same Clojure it always was — we just let you *write* it differently.
The entire library is roughly 300 lines of code across four namespaces: a parser, a precedence table, a compiler, and the public API. We encourage you to read it. It’s well-commented and, dare we say, rather elegant.
Installation
Add as a git dependency:
clojure
;; deps.edn
io.github.flexiana/infix {:git/url "https://github.com/Flexiana/infix"
:git/tag "v1.0.0"
:git/sha "<sha>"}
Or clone it:
bash git clone https://github.com/Flexiana/infix.git
“But This Is Heresy”
We know.
We’ve heard the arguments. S-expressions are homoiconic. Prefix notation eliminates ambiguity. Operator precedence is a source of bugs. Rich Hickey didn’t design Clojure so you could write `a + b` like some *Java developer*.
To which we say: you’re absolutely right. And yet here we are, shipping it anyway. On April 1st, no less — the only day of the year when the Clojure community might forgive us.
The library is real. The tests pass. The macros expand. Whether you *should* use it is a question we leave to your conscience, your team lead, and your local REPL priest.
What’s Next
Infix 1.0.0 is feature-complete and ready for production use. Future enhancements may include:
– Collection operators (`in`, `not-in`)
– Enhanced error messages
– Pattern matching integration
– A formal apology to the Lisp community
Try It
The code is at [github.com/Flexiana/infix](https://github.com/Flexiana/infix). It’s Apache-2.0 licensed. Star it, fork it, or use it as evidence in your next “Clojure is pragmatic, actually” argument.
We regret nothing.
Flexiana is a software consultancy that builds products in Clojure. We take our craft very seriously. Usually.
The post New Era for Clojure: Infix Syntax! appeared first on Flexiana.
(defn say-hello [name]
(println (str "Hello, " name)))
The post New Era for Clojure: Infix Syntax! appeared first on Flexiana.
{align=right loading=lazy style="width:240px"}
I use Debian Linux as it provides a stable and low maintenance operating system, enabling me to focus on getting valuable tasks done. Package updates are well tested and the deb package management system avoid incompatible versions between dependent packages.
The small amount of maintenance is done via the Advance Packaging Tool (Apt), which has a very simple to understand command line, i.e. apt install, apt update, apt upgrade and apt purge.
The constraint of a stable operating system is that some of the latest versions of development tools and programming languages may not be available as part of the distributions package manager.
{align=right loading=lazy}
Simple bash scripts were created to install the latest development tools, made effectively one-liner's where the Download Release Artifacts (DRA) project could be used. The scripts were very simple even when falling back to curl with a few basic Linux commands.
Each shell script installs either an editor, programming language (e.g. Clojure, Rust, Node.js), Terminal UI (TUI) for development or system administration and for a few desktop apps.
debian-linux-post-instal.sh updates all the Debian packages, reading packages to add and remove from a plain text file.dev-tools-install.sh calls each script to install all the development tools, programming languages, TUI's and desktop app's.Practicalli dotfiles - Debian-Linux Scripts{target=_blank .md-button}