Reconstructing Biscuit in Clojure

Authority in Agentic Systems

Over the past few months, experimenting with agentic systems, my thinking kept coming back to one question: how does authority actually move between components? That led me to OCapN and structural authority, then to interpreting OCapN in cloud-native architectures. Those articles are below.



Most systems answer this with identity. You authenticate, get a role, and a policy engine decides what you can do. This tends to work well when everything is centralized. But distributed systems can put pressure on this model. Consider two cases in particular.

An agent that needs to make an authorization decision offline. Or an agent that needs to delegate a narrow slice of its authority to another agent, across a service boundary. In both cases, the token has to carry enough information to be evaluated on its own. Identity alone tends not to be enough for this.

This is the tension I wanted to explore: what if authority were something you carry explicitly, rather than something a central engine derives for you?


Thanks for reading Taorem! Subscribe for free to receive new posts and support my work.


Two Mental Models

Identity-first: You prove who you are. A policy engine looks up what you are allowed to do. Delegation means giving someone a role. Restricting authority means writing more policy rules.

Capability-first: You carry a token. The token contains the authority directly. Delegation means giving someone a narrower version of your token. In this model, the token is designed to enforce the constraints.

The difference tends to matter in distributed systems. With identity-first, you typically need the policy engine available at the point of evaluation. With capability-first, the token is designed to be self-contained, you can verify it without calling back to a central service.

Biscuit is one concrete implementation of this model. It is a token format where authorization logic, facts, rules, and checks, travels inside the token itself, expressed in a Datalog-style reasoning language

What Biscuit Does

A Biscuit token contains three things: facts, rules, and checks.

Facts are statements about the world. “Alice has the role of agent.” “Bob is an internal agent who owns a web-search tool.”

Rules define what can be derived from facts. “If a user has the role of agent, and a target is a known internal agent, that user can read that target.”

Checks are conditions that must hold for the token to be valid. “This token must verify that Alice can use Bob’s web-search tool.”

When you verify a Biscuit token, each block is evaluated within its own scope. Facts in one block are not automatically visible to another. Checks in a block are evaluated against only the facts that block can see. If all checks pass and all signatures are valid, the token is valid.

Delegation works by appending a new block. You can add facts or checks, but existing blocks cannot be changed without invalidating the token, because each block is cryptographically signed. Because each block’s checks are evaluated in isolation, a later block cannot bypass a constraint set by an earlier one. In Biscuit's design, that guarantee is structural, not policy-based.

There are tradeoffs worth considering, and some of them are discussed in the open questions below. The most immediate is complexity, Biscuit uses a Datalog-style reasoning model. Many developers are not familiar with it. The mental model is different from role-based access control or a tool like OPA. This is a real cost.



Rebuilding the Core: Kex

I wanted to understand Biscuit by building a minimal version of it. Not a full implementation. Not production-ready. Just the core ideas, small enough to inspect.

The result is kex, written in Clojure.

Why Clojure? Because facts, rules, and proofs map naturally to immutable maps and vectors. The whole system stays visible. You can evaluate a token in the REPL, inspect the derived facts, and follow the reasoning step by step.

Facts

A fact is a vector:

[:role "alice" :agent]

Facts live inside blocks:

{:facts [[:user "alice"]
         [:role "alice" :agent]]}

Nothing is evaluated yet. This is just structured data.

Rules

A rule describes how to derive new facts:

{:id   :agent-can-read-agents
 :head [:right ?user :read ?agt]
 :body [[:role ?user :agent]
        [:internal-agent ?agt]]}

If [:role "alice" :agent] and [:internal-agent "bob"] exist, this rule derives [:right "alice" :read "bob"]. Rules keep firing until nothing new appears.

Kex implements a minimal Datalog engine using plain Clojure data structures. This tends to keep the system easy to inspect, but recursive rules and negation are not supported. That is a deliberate trade.

Checks

A check is a query that must return at least one result:

{:id    :can-read-web-search
 :query '[[:right "alice" :read "web-search"]]}

If the query returns nothing, the token is invalid. In kex, all facts from all blocks are collected first, then all rules are applied to derive new facts, and finally checks are evaluated against the full combined fact set. In the example above, the check is satisfied because the :can-implies-right rule, added in the delegation block, derives [:right "alice" :read "web-search"] from the [:can "alice" :read "web-search"] fact. Biscuit evaluates each block within its own scope, blocks cannot see each other's private facts. Kex does not implement this isolation.

Issuing a Token

The issuer creates the first block. It defines who Alice is and what agents are allowed to do.

(def token
  (kex/issue
    {:facts  [[:user "alice"] 
              [:role "alice" :agent]]
     :rules  '[{:id   :agent-can-read-agents
                :head [:right ?user :read ?agt]
                :body [[:role ?user :agent]
                       [:internal-agent ?agt]]}]
     :checks []}
    {:private-key (:priv keypair)}))

This signs the block and returns a token. The block cannot be changed after this point.

Delegation

A second service appends a new block. It adds facts about what Alice can access, and a rule that derives read rights from those facts. Because kex collects all facts and rules from all blocks into a single pool before evaluation, this block's facts and rules will be combined with the first block's during derivation.

(def delegated-token
  (kex/attenuate
    token
    {:facts  [[:internal-agent "bob"]
              [:can "alice" :read "web-search"]]
     :rules  '[{:id   :can-implies-right
                :head [:right ?user :read ?res]
                :body [[:can ?user :read ?res]]}]
     :checks []}
    {:private-key (:priv keypair)}))

A new block is appended. The old block is untouched.

Adding a Check

A third party appends one more block. It adds nothing but a check. This token is only valid if Alice can access Bob's web-search tool.

(def auth-token
  (kex/attenuate
    delegated-token
    {:facts  []
     :rules  []
     :checks [{:id :can-read-web-search
               :query '[[:right "alice" :read "web-search"]]}]}
    {:private-key (:priv keypair)}))

Verification and Explanation

(kex/verify auth-token {:public-key (:pub keypair)})

(def decision (kex/evaluate auth-token :explain? true))
(:valid? decision)
(:explain decision)

The explain output shows which rules fired and which facts satisfied each check. You can turn this into a graph:

(kex/graph (:explain decision))

In kex, authorization tends to become something you can read, not just trust.


Thanks for reading Taorem! Subscribe for free to receive new posts and support my work.


What Kex Does Not Do

Kex does not handle revocation, recursive rules, or the full Biscuit serialization format. It is not performance optimized. Do not use it in production.

It also does not fully enforce attenuation. A new block can add broader facts that expand authority if no check prevents it. In Biscuit, block isolation prevents this, a new block cannot see or override facts from another block’s private scope. In kex, that isolation is not implemented.


The full source is available here: https://github.com/serefayar/kex


Open Questions

Building kex made the capability model concrete, but it also made some hard problems more visible.

Revocation and offline verification are in tension. If a token is self-contained and does not need a central service, how do you invalidate it before it expires? Biscuit has partial answers here, but the problem does not go away. It shifts.

Token size grows with each delegation. In systems with deep delegation chains, this can become a practical concern.

Ecosystem fit is also an open question. Most existing infrastructure expects JWT or OAuth tokens. Biscuit does not slot in easily.

Explainability is useful in small systems. Whether it scales to the rule complexity of a real authorization policy is a different question.

And the bigger question: do capability-first models actually solve distributed authorization, or do they mostly reframe it? I do not have a confident answer. Kex is one small experiment in that direction.

Permalink

Senior Software Developer (backend) at Crossref

Senior Software Developer (backend) at Crossref

90

  • Location: Remote and global, to partially overlap with working hours in European time zones.
  • Type: Full-Time, 40 hours a week, Mon-Fri.
  • Remuneration: 90k USD equivalent. We pay salaries in the currency of the country in which you’re based. We arrive at the local USD-equivalent salary by determining the average 5-year USD exchange rate, to stabilise currency fluctuations.
  • Benefits: Check out our Employee handbook for more details on paid time off, unlimited sick time, paid parental and medical leaves, and more.
  • Reports to: Program Technical Lead, Carlos del Ojo Elias
  • Timeline: Advertise in February-March and offer by April

About the role

We are looking for a Senior Software Developer to join our Contributing to the Research Nexus (CRN) program. In this backend-focused role, you will help maintain, extend, and modernize our existing services while also leading the design and implementation of new greenfield systems. The role centres on JVM technologies and cloud-native, distributed systems operating at scale.

Crossref collects a wide range of metadata for an ever-growing and increasingly diverse collection of scholarly outputs. We build and operate services that register, link, and distribute scholarly research metadata. The CRN program develops retrieval, matching, and enrichment services that integrate closely with systems across Crossref.

We are a small organisation with a big impact, and we’re seeking a mission-driven Senior Software Developer who can help maintain and evolve our services, design well-scoped solutions, and contribute to operational reliability through code reviews and documentation. This role will collaborate closely with colleagues across Technology and Programs & Services teams.

Key responsibilities

  • Understand Crossref’s mission and how we support it with our services
  • Work collaboratively in multi-functional project teams
  • Work closely with the Programs & Services Team to solve problems, maintain and improve our services and execute technology changes
  • Collaborate with external stakeholders when needed
  • Produce well-scoped and testable software design and specification
  • Implement and test solutions using Clojure, Kotlin, Java and other relevant technologies
  • Pursue continuous improvement across legacy and green-field codebases
  • Provide code reviews and guidance to other developers regarding development practices and help maintain and improve our development environment
  • Identify and report vulnerabilities and inefficiencies in our services
  • Document and share development plans and changes
  • Be an escalation point for technical support; investigate and respond to occasional but complex user issues

About you

You’re a software developer who enjoys understanding problems end-to-end and making thoughtful technical decisions. You’re comfortable working with ambiguity, you care deeply about users, and you take pride in building systems that last. You don’t need close supervision, but you value collaboration, challenge assumptions constructively, and know when to bring others into technical decisions.

We know no-one will meet all the requirements, but we are looking for people who are willing to learn and like to meet new challenges - please apply if this feels like you!

Essential skills and experience:

  • Minimum 5 years of hands-on experience in software development, engineering, or similar
  • Working knowledge of Clojure or another Lisp / functional language, or demonstrated ability and willingness to learn Clojure quickly.
  • Familiarity with JVM technologies (Kotlin and/or Java)
  • Comfortable working with Git, including code reviews and collaborative workflows
  • Experience contributing to or maintaining production systems, including reading and extending existing codebases
  • Experienced with continuous integration, testing and delivery frameworks, and cloud operations concepts and techniques
  • Familiar with Docker technologies
  • Strong communication skills and a collaborative approach to problem-solving
  • Strong written communication skills, particularly for design discussions and technical documentation
  • Comfortable being part of a geographically distributed team
  • Self-directed, a good manager of your own time, with the ability to focus

Nice-to-have:

  • Curious and tenacious at learning new things and getting to the bottom of problems
  • Strong understanding of functional programming concepts, including immutability, pure functions, higher-order functions, composition
  • Outstanding at interpersonal relations and relationship management
  • Ability to work autonomously while collaborating in a distributed team environment
  • A working understanding of XML and document-oriented systems such as Elasticsearch
  • Some experience with Python, JavaScript or similar scripting languages
  • Experience building tools for online scholarly communication or related fields such as library and information science
  • Comfortable working in open source projects, including public issue tracking, pull requests, and community discussion
  • Experience with JVM web frameworks (Spring, Quarkus, or similar)
  • Direct experience with Clojure in production, especially in open source projects
  • Experience with JVM internals, performance tuning, or memory management
  • Familiarity with the scholarly communications domain

About Crossref & the team

We’re a non-profit membership organisation that exists to make scholarly communications better. We rally the community; tag and share metadata; run an open infrastructure; play with technology; and make tools and services—all to help put research in context.

We envision a rich and reusable open network of relationships connecting research organisations, people, things, and actions; a scholarly record that the global community can build on forever, for the benefit of society. We are working towards this vision of a ‘Research Nexus’ by demonstrating the value of richer and connected open metadata, incentivising people to meet best practices, while making it easier to do so. “We” means 23,000+ members from 160+ countries, 170+ million records, and nearly 2 billion monthly metadata queries from thousands of tools across the research ecosystem. We want to be a sustainable source of complete, open, and global scholarly metadata and relationships.

Take a look at our strategic agenda to see the planned work that aims to achieve the vision. The sustainability area aims to make transparent all the processes and procedures we follow to run the operation long-term, including our financials and our ongoing commitment to the Principles of Open Scholarly Infrastructure (POSI). The governance area describes our board and its role in community oversight.

It also takes a strong team – because reliable infrastructure needs committed people who contribute to and realise the vision, and thrive doing it. We are a distributed group of 50+ dedicated people who take our work seriously, but don’t take ourselves seriously - we like to play quizzes, measure coffee intake, and create 100s of custom slack emojis. We do this through fair policies and working practices, a balanced approach to resourcing, and accountability to each other.

We can offer the successful candidate a challenging and fun environment to work in. Together we are dedicated to our global mission and we are constantly adapting to ensure we get there. Take a look at our organisation chart, the latest Annual Meeting recordings, and our financial information.

Thinking of applying?

We especially encourage applications from people with backgrounds historically under-represented in research and scholarly communications. You can be based anywhere in the world where we can employ staff, either directly or through an employer of record.

We will invite selected candidates to an initial call to discuss the role. Following that, shortlisted candidates will be invited to work on a short (1-2 hours) take-home assignment. This will be followed by a technical interview. The last step will be a panel interview, where you will receive questions in advance. All interviews will be held remotely on Zoom.

Click here to apply!

Applications close on March 10th, 2026.

Anticipated salary for this role is 90k USD-equivalent, paid in local currency. Crossref offers competitive compensation, benefits, flexible work arrangements, professional development opportunities, and a supportive work environment. Check out our Employee Handbook for more details on paid time off, unlimited sick time, paid parental and medical leaves, and more.

Equal opportunities commitment

Crossref is committed to a policy of non-discrimination and equal opportunity for all employees and qualified applicants for employment without regard to race, colour, religion, sex, pregnancy or a condition related to pregnancy, sexual orientation, gender identity or expression, national origin, ancestry, age, physical or mental disability, genetic information, veteran status, uniform service member status, or any other protected class under applicable law. Crossref will make reasonable accommodations for qualified individuals with known disabilities in accordance with applicable law.

Thanks for your interest in joining Crossref. We are excited to hear from you!

Permalink

State of Clojure 2025 Results

Recently, we completed the 2025 State of Clojure survey. You can find the full survey results in this report.

In the report and the highlights below, "Clojure" is often used to refer to the whole ecosystem of Clojure and its dialects as a whole. When relevant, specific dialects are mentioned by name, such as ClojureScript, Babashka, ClojureCLR, etc.

See the following sections for highlights and selected analysis:

Demographics

80 countries represented

80 different countries were represented by respondents to the State of Clojure Survey!

Responses by Country

Responses by country

The Top 10 countries, by count:

1. United States
2. Brazil
3. Germany
4. United Kingdom
5. Finland

6. Sweden
7. France
8. Norway
9. Canada
10. Poland

In fact, the top 4 countries constituted 50.1% of the respondents, so by the numbers, the United States, Brazil, Germany, and the United Kingdom have the same number of Clojure users as the rest of the world.

What if we adjust for population? We can see where Clojure is most concentrated per capita.

1. Finland
2. Norway
3. Sweden
4. Denmark
5. Switzerland

6. Serbia
7. Ireland
8. Netherlands
9. Czech Republic
10. Uruguay

Northern Europe has an especially high concentration of Clojurists.

Responses by Per Capita

Responses for Europe per capita

Also, despite the population differences, Austria, Australia, United States, Brazil, and Canada all have a similar concentration of Clojurists.

82% of Clojure developers have 6 or more years of professional programming experience

Experienced developers continue to be well represented in the Clojure community.

Question: How many years have you been programming professionally?

Professional experience

Clojure attracts developers across a wide range of professional experience

Clojure isn’t just appealing to highly experienced professional developers. Clojure also attracts developers with little to no professional experience. New Clojure developers are from a wide range of professional programming experience.

Professional experience for those with ≤ 1 year of Clojure experience

Professional experience for new Clojurists

Most Clojure developers use Clojure as their primary language

About 2/3 of the respondents use Clojure as their primary language. When Clojure isn’t primary, popularity seems to influence language choice more than a specific language attribute (such as a functional style).

Question: What was your PRIMARY language in the last year?

Top primary languages for Clojurists
Other primary languages for Clojurists

Developer Satisfaction

10% of Clojure developers indicated that they only used Clojure. All others indicated at least one other language they used. This choice, like the primary language, appears to be influenced by popularity, although functional languages (eg. Elixir, Lisp, Scheme/Racket, etc.) appear to be overrepresented versus their general popularity.

Question: What programming languages have you used in the last year? (select all)

Top languages used with Clojure
Other languages used with Clojurists

1 in 10 Clojure developers would quit programming without Clojure

The results below are for developers that selected Clojure and its dialects as their primary language.

Question: If you couldn’t use Clojure, what language would you use instead?

Top alternatives to Clojure
Other alternatives to Clojure

Unsurprisingly, the most popular languages are well represented in the top choices: Java, Python, TypeScript, Go, etc., but notice the functional languages languages are overrepresented versus their general popularity: Elixir, Common Lisp, Scheme/Racket, Haskell, and Erlang.

The design of the Elixir language was influenced by Clojure, so it makes sense that it would stand out as a Clojure alternative versus other functional languages.

Clojure developers are very likely to recommend Clojure to others.

70% of the respondents said they were very likely to recommend Clojure with only 8% saying they would not.

Question: How likely is it that you would recommend Clojure to a friend or colleague?

Net Promoter Score

Industries and Applications

Survey respondents have nearly as much fun with Clojure (52% for hobbies) as more serious uses (71% for work).

Question: How would you characterize your use of Clojure today? (select all)

Use of Clojure today

Fintech, Enterprise Software, and Healthcare are the top industries for Clojure at over 51% combined.

Clojure is used across a range of industries, but Financial Services, Enterprise Software and Healthcare stand out as the top ones. Fintech is 2.5x more popular for Clojure than Enterprise Software and over 4x more popular than Healthcare.

Question: What primary industry do you develop for?

Top industries
Other industries

Clojure is used at large companies and small companies alike.

16% of Clojurists are solo developers. 55% are in an organization of 100 people or less. 26% are in an organization larger than 1000 people—​many are likely part of Nubank, the world’s largest digital-only financial services platform, which employs thousands of Clojure developers.

Question: What is your organization size?

Organization Size

New Users

Clojure continues to attract and retain developers.

15% of respondents have used Clojure for one year or less. That’s roughly equivalent to the 16% that have used Clojure 11-15 years. With 16+ years of experience, 3% of the Clojure community is made up of Clojure’s earliest adopters.

Question: How long have you been using Clojure?

Years of Clojure experience

Using equally sized buckets, it becomes clear that about half the community has 5 or less years of Clojure experience and the other half has 6 or more years.

Years of Clojure experience

Clojure experience bucketed

Functional programming, work, Lisp heritage, and Rich Hickey’s talks are the top reasons for investigating Clojure.

The survey asked developers with ≤ 1 year of Clojure experience to select all the factors that first prompted them to investigate Clojure.

Question: Why did you first start looking at Clojure? (select all)

Seeking a functional programming language

40.20%

Use at work

39.70%

Seeking a modern LISP

39.20%

Inspired by conference talk or video by Rich Hickey or others

32.16%

Seeking a more concise/expressive language on the JVM

14.57%

Seeking a better language for web / full stack programming

13.07%

Inspired by programming writings by prominent authors

12.56%

Enjoyed the community

9.55%

Seeking a language for safe concurrent programming

8.54%

Introduced by a friend or colleague

8.54%

Inspired by using a tool or framework written in Clojure

7.04%

Other (please specify)

6.53%

Business advantages like leverage, hiring, pay

3.52%

Interested in doing music / art programming

2.51%

Use in a university class

1.01%

Nearly half of new Clojure developers are unfamiliar with structured editing.

Structured editing allows a developer to efficiently edit Clojure code while keeping parenthesis and other delimiters balanced. It is especially useful for Lisp-style syntax where the distance between those delimiters ("on the outside") can span many lines of code.

As you can see below, only 19% of experienced Clojurists don’t use it ("manual") or are "not sure" about structured editing. For the inexperienced group, a full 48% don’t use it or are not sure.

Question: Which method do you use to edit Clojure code while balancing parentheses, brackets, etc? (Structured editing)

Respondents with 2 or more years of Clojure experience

Clojure development environment

Respondents with 1 year or less of Clojure experience

Clojure development environment for new Clojurists

Clojure Dialects and Tools

3 out of 5 respondents indicated they use Babashka, which edged out ClojureScript for the #2 spot for the second year in a row.

Question: Which dialects of Clojure have you used in the last year? (select all)

Top Clojure dialects
Other Clojure dialects

Emacs still holds the top spot overall, but new Clojurists are much more likely to use VS Code with Calva.

Across all respondents, Emacs is the most popular, although there is a near perfect 50-50 split between Emacs + Vim and all the others.

Question: What is your primary Clojure development environment?

Clojure development environment

For Clojure developers with one year or less of Clojure experience, Emacs and VS Code essentially trade places.

Respondents with 1 year or less of Clojure experience

Clojure development environment for new Clojurists

70% of Clojure developers have used AI tools for software development, and 12% are considering it.

The industry-wide surge of AI tooling can be seen in the Clojure community. Although a huge majority of Clojure developers have used AI tooling, a disinterested 18% are quite content without it.

Question: Have you used AI tools for software development?

AI coding tool usage

Final Comments

44% of respondents took the time to express appreciation.

After a very long survey, nearly half of the respondents took even more time to express appreciation for others in the Clojure community. You can read their many, many words of appreciation in the full results of the 2025 survey.

Question: Who do you appreciate in the Clojure community and why?

Appreciative responses

In the spirit of thanks, we would like to thank you again for using Clojure and participating in the survey!

Previous Years

We’re celebrating our 15th State of Clojure Survey! 🎉 🥳

What better way to celebrate than by looking back at the years gone by? You can find the full results for this and prior years at the links below:

Permalink

Tetris-playing AI the Polylith way - Part 3

Tetris AI

The focus in this third part of the blog series is to implement an algorithm that computes all valid moves for a piece (Tetromino) in its starting position. We are refining our domain model and improving the readability of parts of the codebase, while continuing to implement the code in Clojure and Python using the component-based Polylith architecture.

Earlier parts:

  • Part 1 - Places a piece on a board. Shows the differences between Clojure and Python and creates the piece and board components.
  • Part 2 - Implements clearing of completed rows. Shows how to get fast feedback when working REPL-driven.

The resulting source code from this post:

Tetris Variants

Tetris has been made in several different variants, such as the handheld Game Boy, the Nintendo NES console, and this Atari arcade game, which I played an unhealthy amount of in my younger days at a pool hall that no longer exists!

Each variant behaves slightly differently when it comes to colours, starting positions, rotation behaviour, and so on.

In most Tetris variants, the pieces start in these rotation states (lying flat) before they start falling:

Tetris pieces

Where on the board the pieces start also varies. For instance, on Nintendo NES and Atari Arcade they start in the fifth x-position, while on Game Boy they start in the fourth:

Start position

In these older versions of Tetris, the pieces rotate only counterclockwise, unlike in some newer games where you can rotate both clockwise and counterclockwise.

The following table compares how pieces rotate across the three mentioned variants:

Rotation table

On Atari, pieces are oriented toward the top-left corner (except the vertical I), while on the other two they mostly rotate around their centre.

In our code, we represent a piece as four [x y] cells:

[[0 1] [1 1] [2 1] [1 2]]

This representation is easy for the code to work with, but poorly communicates the shape of a piece to a human.

The main rule is that code should be written to be easy to understand for the people who read and change it (humans and AI agents).

Let us therefore define a piece like this instead:

(def T0 [&apos---
         &aposxxx
         &apos-x-])

Python:

T0 = [
    "---",
    "xxx",
    "-x-"]

Now we can define all seven pieces and their rotation states for Game Boy (Python code is almost identical):

(ns tetrisanalyzer.piece.settings.game-boy
  (:require [tetrisanalyzer.piece.shape :as shape]))


(def O0 [&apos----
         &apos-xx-
         &apos-xx-
         &apos----])

(def I0 [&apos----
         &apos----
         &aposxxxx
         &apos----])

(def I1 [&apos-x--
         &apos-x--
         &apos-x--
         &apos-x--])

(def Z0 [&apos---
         &aposxx-
         &apos-xx])

(def Z1 [&apos-x-
         &aposxx-
         &aposx--])

(def S0 [&apos---
         &apos-xx
         &aposxx-])

(def S1 [&aposx--
         &aposxx-
         &apos-x-])

(def J0 [&apos---
         &aposxxx
         &apos--x])

(def J1 [&apos-xx
         &apos-x-
         &apos-x-])

(def J2 [&aposx--
         &aposxxx
         &apos---])

(def J3 [&apos-x-
         &apos-x-
         &aposxx-])

(def L0 [&apos---
         &aposxxx
         &aposx--])

(def L1 [&apos-x-
         &apos-x-
         &apos-xx])

(def L2 [&apos--x
         &aposxxx
         &apos---])

(def L3 [&aposxx-
         &apos-x-
         &apos-x-])

(def T0 [&apos---
         &aposxxx
         &apos-x-])

(def T1 [&apos-x-
         &apos-xx
         &apos-x-])

(def T2 [&apos-x-
         &aposxxx
         &apos---])

(def T3 [&apos-x-
         &aposxx-
         &apos-x-])

(def pieces [[O0]
             [I0 I1]
             [Z0 Z1]
             [S0 S1]
             [J0 J1 J2 J3]
             [L0 L1 L2 L3]
             [T0 T1 T2 T3]])

(def shapes (shape/shapes pieces))

The shapes function at the end converts the pieces into the format the code uses:

[;; O
 [[[1 1] [2 1] [1 2] [2 2]]]
 ;; I
 [[[0 2] [1 2] [2 2] [3 2]]
  [[1 0] [1 1] [1 2] [1 3]]]
 ;; Z
 [[[0 1] [1 1] [1 2] [2 2]]
  [[1 0] [0 1] [1 1] [0 2]]]
 ;; S
 [[[1 1] [2 1] [0 2] [1 2]]
  [[0 0] [0 1] [1 1] [1 2]]]
 ;; J
 [[[0 1] [1 1] [2 1] [2 2]]
  [[1 0] [2 0] [1 1] [1 2]]
  [[0 0] [0 1] [1 1] [2 1]]
  [[1 0] [1 1] [0 2] [1 2]]]
 ;; L
 [[[0 1] [1 1] [2 1] [0 2]]
  [[1 0] [1 1] [1 2] [2 2]]
  [[2 0] [0 1] [1 1] [2 1]]
  [[0 0] [1 0] [1 1] [1 2]]]
 ;; T
 [[[0 1] [1 1] [2 1] [1 2]]
  [[1 0] [1 1] [2 1] [1 2]]
  [[1 0] [0 1] [1 1] [2 1]]
  [[1 0] [0 1] [1 1] [1 2]]]]

The test for the shape function looks like this:

(ns tetrisanalyzer.piece.shape-test
  (:require [clojure.test :refer :all]
            [tetrisanalyzer.piece.shape :as shape]))

(deftest converts-a-piece-shape-grid-to-a-vector-of-xy-cells
  (is (= [[2 0]
          [1 1]
          [2 1]
          [1 2]]
         (shape/shape [&apos--x-
                       &apos-xx-
                       &apos-x--
                       &apos----]))))

Python:

from tetrisanalyzer.piece.shape import shape


def test_converts_a_piece_shape_grid_to_a_list_of_xy_cells():
    assert [[2, 0],
            [1, 1],
            [2, 1],
            [1, 2]] == shape(["--x-",
                              "-xx-",
                              "-x--",
                              "----"]
    )

Implementation in Clojure:

(ns tetrisanalyzer.piece.shape)

(defn cell [x character y]
  (when (= \x character)
    [x y]))

(defn row-cells [y row]
  (keep-indexed #(cell %1 %2 y)
                (str row)))

(defn shape [piece-grid]
  (vec (mapcat identity
               (map-indexed row-cells piece-grid))))

(defn shapes [piece-grids]
  (mapv #(mapv shape %)
        piece-grids))

If you are new to Clojure, here are some explanatory examples of a couple of the functions:

(map-indexed vector ["I" "love" "Tetris"])

;; ([0 "I"] [1 "love"] [2 "Tetris"])

The map-indexed function iterates over "I", "love", and "Tetris", and builds a new list where each element is created by calling vector with the index, which is equivalent to:

(list (vector 0 "I")
      (vector 1 "love")
      (vector 2 "tetris"))

;; ([0 "I"] [1 "love"] [2 "Tetris"])

The function keep-indexed works in the same way, but only keeps values that aren&apost nil, hence the use of when:

;; %1 = first argument (index)
;; %2 = second argument (value)
(keep-indexed #(when %2 [%1 %2]) 
              ["I" nil "Tetris"])

;; ([0 "I"] [2 "Tetris"])

Implementation in Python:

def shape(piece_grid):
    return [
        [x, y]
        for y, row in enumerate(piece_grid)
        for x, ch in enumerate(row)
        if ch == "x"]

def shapes(pieces_grids):
    return [
        [shape(piece_grid) for piece_grid in piece_grids]
        for piece_grids in pieces_grids]

Here we use list comprehension to convert the data into [x, y] cells. The enumerate function is equivalent to Clojure’s map-indexed, in that it adds an index (0, 1, 2, …) to each element.

Domain Modelling

The new code that calculates the valid moves for a piece in its starting position has to live somewhere. We need to be able to move and rotate a piece, and check whether the target position on the board is free.

In object-oriented programming we have several options. We could write piece.set(board), board.set(piece), or maybe move.set(piece, board), while making every effort not to expose the internal representation.

In functional programming, we have more freedom and don&apost try to hide how we represent our data. The fact that the board is stored as a two-dimensional vector is no secret, and it isn’t just board that can create updated copies of this two-dimensional vector.

Code usually belongs where we expect to find it. We have the function set-piece, which, according to this reasoning, should live in piece, so I moved it from board where I&aposd put it earlier. The new placements function also goes in piece, since it&aposs about finding valid moves for a piece. Our domain model now looks like this:

Components

Inside each component we list what belongs to its interface (what&aposs public), and the arrow shows that piece calls functions in board.

We split the implementation across the namespaces move, placement, and visit, which we put in the move package:

▾ tetris-polylith
  ▸ bases
  ▾ components
    ▸ board
    ▾ piece
      ▾ src
        ▸ settings
        ▾ move
          move.clj
          placement.clj
          visit.clj
        bitmask.clj
        interface.clj
        piece.clj
        shape.clj
      ▾ test
        ▾ move
          move_test.clj
          placement_test.clj
          visit_test.clj
        piece_test.clj
        shape_test.clj
  ▸ development
  ▸ projects

The move-test looks like this:

(ns tetrisanalyzer.piece.move.move-test
  (:require [clojure.test :refer :all]
            [tetrisanalyzer.piece.piece :as piece]
            [tetrisanalyzer.piece.move.move :as move]
            [tetrisanalyzer.piece.bitmask :as bitmask]
            [tetrisanalyzer.board.interface :as board]
            [tetrisanalyzer.piece.settings.atari-arcade :as atari-arcade]))

(def x 2)
(def y 1)
(def rotation 0)
(def S piece/S)
(def shapes atari-arcade/shapes)
(def bitmask (bitmask/rotation-bitmask shapes S))
(def piece (piece/piece S rotation shapes))

(def board (board/board [&aposxxxxxxxx
                         &aposxxx--xxx
                         &aposxx--xxxx
                         &aposxxxxxxxx]))

(deftest valid-move
  (is (= true
         (move/valid-move? board x y S rotation shapes))))

(deftest valid-left-move
  (is (= [2 1 0]
         (move/left board (inc x) y S rotation nil shapes))))

(deftest invalid-left-move
  (is (= nil
         (move/left board x y S rotation nil shapes))))

(deftest valid-right-move
  (is (= [2 1 0]
         (move/right board (dec x) y S rotation nil shapes))))

(deftest invalid-right-move
  (is (= nil
         (move/right board x (dec y) S rotation nil shapes))))

(deftest unoccupied-down-move
  (is (= [[2 1 0] nil]
         (move/down board x (dec y) S rotation nil shapes))))

(deftest down-move-hits-ground
  (is (= [nil [[2 1 0]]]
         (move/down board x y S rotation nil shapes))))

(deftest valid-rotation
  (is (= [2 1 0]
         (move/rotate board x y S (dec rotation) bitmask shapes))))

(deftest invalid-rotation-without-kick
  (is (= nil
         (move/rotate board (inc x) y S (inc rotation) bitmask shapes))))

(deftest valid-rotation-with-kick
  (is (= [2 1 0]
         (move/rotate-with-kick board (inc x) y S (inc rotation) bitmask shapes))))

(deftest invalid-move-outside-board
  (is (= false
         (move/valid-move? board 10 -10 S rotation shapes))))

The first test, valid-move, checks that the S piece:

[&apos-xx
 &aposxx-]

Can be placed at position x=2, y=1, on the board:

[&aposxxxxxxxx
 &aposxxx--xxx
 &aposxx--xxxx
 &aposxxxxxxxx]

Beyond that, we test various valid moves and rotations into the empty area, plus invalid moves outside the board.

In Tetris there&aposs something called kick, or wall kick. When you rotate a piece and that position is occupied on the board, one step left is also tried (x-1). On Nintendo NES this is turned off, while it&aposs enabled in the other two variants we support here. In newer Tetris games, other placements besides x-1 are sometimes tested as well.

The implementation looks like this:

(ns tetrisanalyzer.piece.move.move
  (:require [tetrisanalyzer.piece.piece :as piece]))

(defn cell [board x y [cx cy]]
  (or (get-in board [(+ y cy) (+ x cx)])
      piece/X))

(defn valid-move? [board x y p rotation shapes]
  (every? zero?
          (map #(cell board x y %)
               (piece/piece p rotation shapes))))

(defn left [board x y p rotation _ shapes]
  (when (valid-move? board (dec x) y p rotation shapes)
    [(dec x) y rotation]))

(defn right [board x y p rotation _ shapes]
  (when (valid-move? board (inc x) y p rotation shapes)
    [(inc x) y rotation]))

(defn down
  "Returns [down-move placement] where:
   - down-move: next move when moving down or nil if blocked
   - placement: final placement if blocked, or nil if can move down"
  [board x y p rotation _ shapes]
  (if (valid-move? board x (inc y) p rotation shapes)
    [[x (inc y) rotation] nil]
    [nil [[x y rotation]]]))

(defn rotate [board x y p rotation bitmask shapes]
  (let [new-rotation (bit-and (inc rotation) bitmask)]
    (when (valid-move? board x y p new-rotation shapes)
      [x y new-rotation])))

(defn rotate-with-kick [board x y p rotation bitmask shapes]
  (or (rotate board x y p rotation bitmask shapes)
      (rotate board (dec x) y p rotation bitmask shapes)))

(defn rotation-fn [rotation-kick?]
  (if rotation-kick?
    rotate-with-kick
    rotate))

The functions are fairly straightforward, so let us instead look at the code that helps us keep track of which moves have already been visited:

(ns tetrisanalyzer.piece.move.visit)

(defn visited? [visited-moves x y rotation]
  (if-let [visited-rotations (get-in visited-moves [y x])]
    (not (zero? (bit-and visited-rotations
                         (bit-shift-left 1 rotation))))
    true)) ;; Cells outside the board are treated as visited

(defn visit [visited-moves x y rotation]
  (assoc-in visited-moves [y x] (bit-or (get-in visited-moves [y x])
                                        (bit-shift-left 1 rotation))))

Calling the standard bit-shift-left function returns a set bit in one of the four lowest bits:

rotationbit
00001
10010
20100
31000

These "flags" are used to mark that we&aposve visited a given [x y rotation] move on the board. Note that we pass a "visited board" (visited-moves) into visit and get back a copy where the [x y] cell has a bit set for the given rotation. This “copying” is very fast and memory-efficient, see “structural sharing” under Data Structures.

The tests look like the following:

(ns tetrisanalyzer.piece.move.visit-test
  (:require [clojure.test :refer :all]
            [tetrisanalyzer.piece.move.visit :as visit]))

(def x 2)
(def y 1)
(def rotation 3)
(def unvisited [[0 0 0 0]
                [0 0 0 0]])

(deftest move-is-not-visited
  (is (= false
         (visit/visited? unvisited x y rotation))))

(deftest move-is-visited
  (let [visited (visit/visit unvisited x y rotation)]
    (is (= true
           (visit/visited? visited x y rotation)))))

Python:

from tetrisanalyzer.piece.move.visit import is_visited, visit

X = 2
Y = 1
ROTATION = 3
UNVISITED = [
    [0, 0, 0, 0],
    [0, 0, 0, 0]]


def test_move_is_not_visited():
    assert is_visited(UNVISITED, X, Y, ROTATION) is False


def test_move_is_visited():
    visited = [row[:] for row in UNVISITED]
    visit(visited, X, Y, ROTATION)
    assert is_visited(visited, X, Y, ROTATION) is True

We have now laid the groundwork to implement the placements function that computes all valid moves for a piece in its starting position.

We start with the test:

(ns tetrisanalyzer.piece.move.placement-test
  (:require [clojure.test :refer :all]
            [tetrisanalyzer.piece.piece :as piece]
            [tetrisanalyzer.piece.move.placement :as placement]
            [tetrisanalyzer.piece.settings.atari-arcade :as atari-arcade]))

(def start-x 2)
(def sorter (juxt second first last))

(def board [[0 0 0 0 0 0]
            [0 0 1 1 0 0]
            [0 0 1 0 0 1]
            [0 0 1 1 1 1]])

(def shapes atari-arcade/shapes)

;; Start position of the J piece:
;; --JJJ-
;; --xxJ-
;; --x--x
;; --xxxx
(deftest placements--without-rotation-kick
  (is (= [[2 0 0]
          [3 0 0]]
         (sort-by sorter (placement/placements board piece/J start-x false shapes)))))

;; With rotation kick, checking if x-1 fits:
;; -JJ---
;; -Jxx--
;; -Jx--x
;; --xxxx
(deftest placements--with-rotation-kick
  (is (= [[1 0 1]
          [2 0 0]
          [3 0 0]
          [0 1 1]]
         (sort-by sorter (placement/placements board piece/J start-x true shapes)))))

This tests that we get back the valid [x y rotation] positions where a piece can be placed on the board from its starting position.

The implementation:

(ns tetrisanalyzer.piece.move.placement
  (:require [tetrisanalyzer.piece.move.move :as move]
            [tetrisanalyzer.piece.move.visit :as visit]
            [tetrisanalyzer.board.interface :as board]
            [tetrisanalyzer.piece.bitmask :as bitmask]))

(defn ->placements [board x y p rotation bitmask valid-moves visited-moves rotation-fn shapes]
  (loop [next-moves (list [x y rotation])
         placements []
         valid-moves valid-moves
         visited-moves visited-moves]
    (if-let [[x y rotation] (first next-moves)]
      (let [next-moves (rest next-moves)]
        (if (visit/visited? visited-moves x y rotation)
          (recur next-moves placements valid-moves visited-moves)
          (let [[down placement] (move/down board x y p rotation bitmask shapes)
                moves (keep #(% board x y p rotation bitmask shapes)
                            [move/left
                             move/right
                             rotation-fn
                             (constantly down)])]
            (recur (into next-moves moves)
                   (concat placements placement)
                   (conj valid-moves [x y rotation])
                   (visit/visit visited-moves x y rotation)))))
      placements)))

(defn placements [board p x kick? shapes]
  (let [y 0
        rotation 0
        bitmask (bitmask/rotation-bitmask shapes p)
        visited-moves (board/empty-board board)
        rotation-fn (move/rotation-fn kick?)]
    (if (move/valid-move? board x y p rotation shapes)
      (->placements board x y p rotation bitmask [] visited-moves rotation-fn shapes)
      [])))

Let us walk through the following section in ->placements:

(loop [next-moves (list [x y rotation])
       placements []
       valid-moves valid-moves
       visited-moves visited-moves]

These four lines initialise the data we&aposre looping over: next-moves is the list of moves we need to process (it grows and shrinks as we go), and placements accumulates valid moves.

Since Clojure doesn’t support tail recursion, we use loop instead, to avoid stack overflow on boards larger than 10×20.

(if-let [[x y rotation] (first next-moves)]

Retrieves the next move from next-moves and continues with the code immediately after, or returns placements (the last line in the function, representing all valid moves) if next-moves is empty.

(let [next-moves (rest next-moves)]

Drops the first element from next-moves, the one we just picked.

(if (visit/visited? visited-moves x y rotation)

If we&aposve already visited this move, continue with:

(recur next-moves placements valid-moves visited-moves)

Which continues our search for valid moves (the line after (loop [...]) by moving on to the next move to evaluate.

Otherwise, if the move hasn&apost been visited, we do:

(let [[down placement] (move/down board x y p rotation bitmask shapes)
     ...]

This sets down to the next downward move (if free) or placement if we can&apost move down, which happens when we hit the bottom or when part of the "stack" is in the way.

For these lines:

(keep #(% board x y p rotation bitmask shapes)
      [move/left
       move/right
       rotation-fn
       (constantly down)])

The % gets replaced with each function in the vector, which is equivalent to:

[(move/left board x y p rotation bitmask shapes)
 (move/right board x y p rotation bitmask shapes)
 (rotation-fn board x y p rotation bitmask shapes)
 (down board x y p rotation bitmask shapes)]

These function calls generate all possible moves (including rotations), returning [x y rotation] for positions that are free on the board, or nil if occupied. The keep function filters out nil values, leaving only valid moves in moves.

Finally we execute:

(recur (into next-moves moves)
       (concat placements placement)
       (conj valid-moves [x y rotation])
       (visit/visit visited-moves x y rotation))

Which calls loop again with:

  • next-moves updated with any new moves
  • placements updated with any valid placement
  • valid-moves updated with the current move
  • visited-moves with the current move marked as visited

This keeps going until next-moves is empty, and then we return placements.

The function that kicks everything off and returns valid moves for a piece in its starting position:

(defn placements [board p x kick? shapes]
  (let [y 0
        rotation 0
        bitmask (bitmask/rotation-bitmask shapes p)
        visited-moves (board/empty-board board)
        rotation-fn (move/rotation-fn kick?)]
    (if (move/valid-move? board x y p rotation shapes)
      (->placements board x y p rotation bitmask [] visited-moves rotation-fn shapes)
      [])))
  • board: a two-dimensional vector representing the board, usually 10x20.
  • p: piece index (0, 1, 2, 3, 4, 5, or 6).
  • x: which column the 4x4 grid starts in (where the piece sits). First column is 0.
  • y: set to 0 (top row for the 4x4 grid).
  • rotation: set to 0 (starting rotation).
  • bitmask: used when iterating over rotations so that it wraps back to 0 after reaching the maximum number of rotations it can perform.
  • visited-moves: has the same structure as a board, a two-dimensional array, usually 10x20.
  • rotation-fn: returns the right rotation function depending on whether kick is enabled. Also tries position x-1 if kick? is true.
  • shapes: the shapes for all pieces and their rotation states, stored as [x y] cells.
  • (if (move/valid-move? board x y p rotation shapes): we need to check whether the initial position is free; if not, return an empty vector.
  • (->placements board x y p rotation bitmask [] visited-moves rotation-fn shapes) computes the valid moves.

Implementation in Python:

from collections import deque

from tetrisanalyzer import board as board_ifc
from tetrisanalyzer.piece import piece
from tetrisanalyzer.piece.bitmask import rotation_bitmask
from tetrisanalyzer.piece.move import move
from tetrisanalyzer.piece.move import visit

def _placements(board, x, y, p, rotation, bitmask, valid_moves, visited_moves, rotation_move_fn, shapes):
    next_moves = deque([[x, y, rotation]])
    valid_placements = []

    while next_moves:
        x, y, rotation = next_moves.popleft()

        if visit.is_visited(visited_moves, x, y, rotation):
            continue

        down_move, placement = move.down(board, x, y, p, rotation, bitmask, shapes)

        moves = [
            move.left(board, x, y, p, rotation, bitmask, shapes),
            move.right(board, x, y, p, rotation, bitmask, shapes),
            rotation_move_fn(board, x, y, p, rotation, bitmask, shapes),
            down_move]

        moves = [m for m in moves if m is not None]

        next_moves.extend(moves)

        if placement is not None:
            valid_placements.extend(placement)

        valid_moves.append([x, y, rotation])
        visit.visit(visited_moves, x, y, rotation)

    return valid_placements


def placements(board, p, start_x, kick, shapes):
    y = 0
    rotation = 0
    bitmask = rotation_bitmask(shapes, p)
    visited_moves = board_ifc.empty_board(board_ifc.width(board), board_ifc.height(board))
    rotation_move_fn = move.rotation_fn(kick)

    if not move.is_valid_move(board, start_x, y, p, rotation, shapes):
        return []

    return _placements(board, start_x, y, p, rotation, bitmask, [], visited_moves, rotation_move_fn, shapes)

The code follows the same algorithm as in Clojure. We use deque because it&aposs slightly faster than a list when performing both popleft and extend.

Testing

Finally, we run our tests:

$> cd ~/source/tetrisanalyzer/langs/clojure/tetris-polylith
$> poly test :dev
Projects to run tests from: development

Running tests for the development project using test runner: Polylith built-in clojure.test runner...
Running tests from the development project, including 2 bricks: board, piece

Testing tetrisanalyzer.board.core-test

Ran 1 tests containing 1 assertions.
0 failures, 0 errors.

Test results: 1 passes, 0 failures, 0 errors.

Testing tetrisanalyzer.board.clear-rows-test

Ran 1 tests containing 1 assertions.
0 failures, 0 errors.

Test results: 1 passes, 0 failures, 0 errors.

Testing tetrisanalyzer.board.grid-test

Ran 2 tests containing 2 assertions.
0 failures, 0 errors.

Test results: 2 passes, 0 failures, 0 errors.

Testing tetrisanalyzer.piece.shape-test

Ran 1 tests containing 1 assertions.
0 failures, 0 errors.

Test results: 1 passes, 0 failures, 0 errors.

Testing tetrisanalyzer.piece.move.placement-test

Ran 2 tests containing 2 assertions.
0 failures, 0 errors.

Test results: 2 passes, 0 failures, 0 errors.

Testing tetrisanalyzer.piece.move.move-test

Ran 11 tests containing 11 assertions.
0 failures, 0 errors.

Test results: 11 passes, 0 failures, 0 errors.

Testing tetrisanalyzer.piece.move.visit-test

Ran 2 tests containing 2 assertions.
0 failures, 0 errors.

Test results: 2 passes, 0 failures, 0 errors.

Testing tetrisanalyzer.piece.piece-test

Ran 1 tests containing 1 assertions.
0 failures, 0 errors.

Test results: 1 passes, 0 failures, 0 errors.

Execution time: 0 seconds

Python:

$> cd ~/source/tetrisanalyzer/langs/python/tetris-polylith-uv
$> uv run pytest
======================================================================================================= test session starts ========================================================================================================
platform darwin -- Python 3.13.11, pytest-9.0.2, pluggy-1.6.0
rootdir: /Users/tengstrand/source/tetrisanalyzer/langs/python/tetris-polylith-uv
configfile: pyproject.toml
collected 21 items

test/components/tetrisanalyzer/board/test_clear_rows.py .                                                                                                                                                                    [  4%]
test/components/tetrisanalyzer/board/test_core.py ..                                                                                                                                                                         [ 14%]
test/components/tetrisanalyzer/board/test_grid.py ..                                                                                                                                                                         [ 23%]
test/components/tetrisanalyzer/piece/move/test_move.py ...........                                                                                                                                                           [ 76%]
test/components/tetrisanalyzer/piece/move/test_placement.py ..                                                                                                                                                               [ 85%]
test/components/tetrisanalyzer/piece/move/test_visit.py ..                                                                                                                                                                   [ 95%]
test/components/tetrisanalyzer/piece/test_shape.py .                                                                                                                                                                         [100%]

======================================================================================================== 21 passed in 0.02s ========================================================================================================

Nice, all tests passed!

Summary

In this third post, I took on the not entirely trivial task of computing all valid moves for a piece in its starting position.

I avoided implementing it as a recursive algorithm, since that would limit how large our boards can get.

We reminded ourselves that code should live where we expect to find it.

We also took the opportunity to make the code easier to work with, by specifying pieces in a more readable way, and with that change we could easily support three different Tetris variants.

Hope you had just as much fun as I did 😃

Happy Coding!

Permalink

Babashka 1.12.215: Revenge of the TUIs

Babashka is a fast-starting native Clojure scripting runtime. It uses SCI to interpret Clojure and compiles to a native binary via GraalVM, giving you Clojure&aposs power with near-instant startup. It&aposs commonly used for shell scripting, build tooling, and small CLI applications. If you don&apost yet have bb installed, you can with brew:

brew install borkdude/brew/babashka

or bash:

bash <(curl -s https://raw.githubusercontent.com/babashka/babashka/master/install)

This release is, in my opinion, a game changer. With JLine3 bundled, you can now build full terminal user interfaces in babashka. The bb repl has been completely overhauled with multi-line editing, completions, and eldoc. deftype now supports map interfaces, making bb more compatible with existing libraries like core.cache. SCI has had many small improvements, making riddley compatible too. Riddley is used in Cloverage, a code coverage library for Clojure, which now also works with babashka (Cloverage PR pending).

Babashka conf 2026

But first, let me mention an exciting upcoming event! Babashka conf is happening again for the second time! The first time was 2023 in Berlin. This time it&aposs in Amsterdam. The Call for Proposals is open until the end of February, so there is still time to submit your talk or workshop. We are also looking for one last gold sponsor (500 euros) to cover all costs.

Highlights

JLine3 and TUI support

Babashka now bundles JLine3, a Java library for building interactive terminal applications. You get terminals, line readers with history and tab completion, styled output, keyboard bindings, and the ability to reify custom completers, parsers, and widgets — all from bb scripts.

JLine3 works on all platforms, including Windows PowerShell and cmd.exe.

Here&aposs a simple interactive prompt that reads lines from the user until EOF (Ctrl+D):

(import &apos[org.jline.terminal TerminalBuilder]
        &apos[org.jline.reader LineReaderBuilder])

(let [terminal (-> (TerminalBuilder/builder) (.build))
      reader   (-> (LineReaderBuilder/builder)
                   (.terminal terminal)
                   (.build))]
  (try
    (loop []
      (when-let [line (.readLine reader "prompt> ")]
        (println "You typed:" line)
        (recur)))
    (catch org.jline.reader.EndOfFileException _
      (println "Goodbye!"))
    (finally
      (.close terminal))))

babashka.terminal namespace

A new babashka.terminal namespace exposes a tty? function to detect whether stdin, stdout, or stderr is connected to a terminal:

(require &apos[babashka.terminal :refer [tty?]])

(when (tty? :stdout)
  (println "Interactive terminal detected, enabling colors"))

This accepts :stdin, :stdout, or :stderr as argument. It uses JLine3&aposs terminal provider under the hood.

This is useful for scripts that want to behave differently when piped vs. run interactively, for example enabling colored output or progress bars only in a terminal.

charm.clj compatibility

charm.clj is a new Clojure library for building terminal user interfaces using the Elm architecture (Model-Update-View). It provides components like spinners, text inputs, lists, paginators, and progress bars, with support for ANSI/256/true color styling and keyboard/mouse input handling.

charm.clj is now compatible with babashka (or rather, babashka is now compatible with charm.clj), enabled by the combination of JLine3 support and other interpreter improvements in this release. This means you can build rich TUI applications that start instantly as native binaries.

Here&aposs a complete counter example you can save as a single file and run with bb:

#!/usr/bin/env bb

(babashka.deps/add-deps
 &apos{:deps {io.github.TimoKramer/charm.clj {:git/sha "cf7a6c2fcfcccc44fcf04996e264183aa49a70d6"}}})

(require &apos[charm.core :as charm])

(def title-style
  (charm/style :fg charm/magenta :bold true))

(def count-style
  (charm/style :fg charm/cyan
               :padding [0 1]
               :border charm/rounded-border))

(defn update-fn [state msg]
  (cond
    (or (charm/key-match? msg "q")
        (charm/key-match? msg "ctrl+c"))
    [state charm/quit-cmd]

    (or (charm/key-match? msg "k")
        (charm/key-match? msg :up))
    [(update state :count inc) nil]

    (or (charm/key-match? msg "j")
        (charm/key-match? msg :down))
    [(update state :count dec) nil]

    :else
    [state nil]))

(defn view [state]
  (str (charm/render title-style "Counter App") "\n\n"
       (charm/render count-style (str (:count state))) "\n\n"
       "j/k or arrows to change\n"
       "q to quit"))

(charm/run {:init {:count 0}
            :update update-fn
            :view view
            :alt-screen true})
charm.clj counter example running in babashka

More examples can be found here.

Deftype with map interfaces

Until now, deftype in babashka couldn&apost implement JVM interfaces like IPersistentMap, ILookup, or Associative. This meant libraries that define custom map-like types, a very common Clojure pattern, couldn&apost work in babashka.

Starting with this release, deftype supports map interfaces. Your deftype must declare IPersistentMap to signal that you want a full map type. Other map-related interfaces like ILookup, Associative, Counted, Seqable, and Iterable are accepted freely since the underlying class already implements them.

This unlocks several libraries that were previously incompatible:

  • core.cache: all cache types (BasicCache, FIFOCache, LRUCache, TTLCache, LUCache) work unmodified
  • linked: insertion-ordered maps and sets

Riddley and Cloverage compatibility

Riddley is a Clojure library for code walking that many other libraries depend on. Previously, SCI&aposs deftype and case did not macroexpand to the same special forms as JVM Clojure, which broke riddley&aposs walker. Several changes now align SCI&aposs behavior with Clojure: deftype macroexpands to deftype*, case to case*, and macroexpand-1 now accepts an optional env map as second argument (inspired by how the CLJS analyzer API works). Together these changes enable riddley and tools built on it, like cloverage and Specter, to work with bb.

Riddley has moved to clj-commons, thanks to Zach Tellman for transferring it. I&aposd like to thank Zach for all his contributions to the Clojure community over the years. Version 0.2.2 includes bb compatibility, which was one of the first PRs merged after the transfer. Cloverage compatibility has been submitted upstream, all 75 cloverage tests pass on both JVM and babashka.

Console REPL improvements

The bb repl experience has been significantly improved with JLine3 integration. You no longer need rlwrap to get a comfortable console REPL:

  • Multi-line editing: the REPL detects incomplete forms and continues reading on the next line with a #_=> continuation prompt
  • Tab completion: Clojure-aware completions powered by SCI, including keywords (:foo, ::foo, ::alias/foo)
Tab completions in bb repl
  • Ghost text: as you type, the common completion prefix appears as faint inline text after the cursor. Press TAB to accept.
Ghost text in bb repl
  • Eldoc: automatic argument help — when your cursor is inside a function call like (map |), the arglists are displayed below the prompt
  • Doc-at-point: press Ctrl+X Ctrl+D to show full documentation for the symbol at the cursor
  • Persistent history: command history saved across sessions in ~/.bb_repl_history
  • Ctrl+C handling: first press on an empty prompt warns, second press exits

Many of these features were inspired by rebel-readline, Leiningen&aposs REPL, and Node.js&aposs REPL.

SCI improvements

Under the hood, SCI (the interpreter powering babashka) received many improvements in this cycle:

  • Functional interface adaptation for instance targets: you can now write (let [^Predicate p even?] (.test p 42)) and SCI will adapt the Clojure function to the functional interface automatically.
  • Type tag inference: SCI now infers type tags from let binding values to binding names, reducing the need for explicit type hints in interop-heavy code.
  • Several bug fixes: read with nil/false as eof-value, letfn with duplicate function names, ns-map not reflecting shadowed vars, NPE in resolve, and .method on class objects routing incorrectly.

Other improvements

  • Support multiple catch clauses in combination with ^:sci/error
  • Fix satisfies? on protocols with proxy
  • Support reify with java.time.temporal.TemporalQuery
  • Fix reify with methods returning int/short/byte/float primitives
  • nREPL server now uses non-daemon threads so the process stays alive without @(promise)
  • Add clojure.test.junit as built-in source namespace
  • Add cp437 (IBM437) charset support in native binary via selective GraalVM charset Feature, avoiding the ~5MB binary size increase from AddAllCharsets. More charsets can be added on request.

For the full list of changes including new Java classes and library bumps, see the changelog.

Thanks

Thank you to all the contributors who helped make this release possible. Special thanks to everyone who reported issues, tested pre-release builds from babashka-dev-builds, and provided feedback.

Thanks to Clojurists Together and all babashka sponsors and contributors for their ongoing support. Your sponsorship makes it possible to keep developing babashka.

And thanks to all babashka users: you make this project what it is. Happy scripting!

Permalink

Last Call for Q2 2026 Funding Survey

Greetings Clojurists!

We are about to close our Q2 2026 Funding Survey which helps inform our Q2 project awards. It is not a heavy lift - maybe 5 minutes of your time. But your input is invaluable! A link to the survey was sent to your email in the last few weeks - and just in case it made its way to spam, you can look for “We Need Your Input - Q2 2026 Funding”. The survey closes midnight PST on February 21, 2026.

Thanks as always for your support of Clojurists Together and for being a part of this awesome community.

Any questions, please email me at kdavis@clojuriststogether.org

Kathy Davis Program Manager Clojurists Together Foundation

Permalink

ClojureScript Guide: Why Modern Devs Need It Now (2026 Edition)

Why is ClojureScript Such a Big Deal and Matter So Much for Modern Web Developers in 2026?

Simple. Today, developers need speed, scalability, and a smooth developer experience (DX) in web development. ClojureScript meets all three requirements effectively.

Write Clojure, and it turns into optimized JavaScript using Google Closure. It helps deliver scalable user interfaces and makes it much easier to share code across the full stack.

Compared to plain JavaScript, ClojureScript offers several significant enhancements. 

  • Functional immutability keeps code clean and helps to dodge bugs. 
  • shadow-cljs, help with REPL hot-reloading. Add new code changes and see them instantly. 
  • And core.async handles async workflows without callback overhead.

This is more than a tool; it’s a shift in approach. ClojureScript changes the way many developers think about creating applications. It is about making things faster, cleaner, and much more reliable.

So, What’s ClojureScript All About? Beginner Guide

What’s ClojureScript All About? Beginner Guide

ClojureScript takes Clojure- a Lisp dialect- and turns it into lean, optimized JavaScript using the Google Closure toolchain. It delivers all the perks of functional, declarative code in that classic Lisp syntax, but it runs fast right in the browser.

Lisp Syntax for Concise, Immutable Code

Why bother with Lisp syntax? As complexity grows, JavaScript becomes harder to reason about. ClojureScript uses Hiccup syntax and keeps the data immutable. The code is kept neat and simple to understand, so it is less likely to break. New team members can dive in and build features rather than tracking bugs.

Google Closure Optimization

The Closure Compiler removes dead code and optimizes the remaining code. That means smaller files, faster load times, and lighter apps overall.

Functional Programming Foundation

At its core, ClojureScript leans hard into functional programming- immutability, pure functions, and simple, declarative UIs. When you use tools like Reagent or re-frame, handling app state and building complex interfaces becomes much smoother. 

Beginner‑Friendly Learning Curve

ClojureScript may feel challenging at first, especially for developers accustomed to JavaScript. Once they learn immutability and pure functions, building simple, declarative UIs becomes easier. Suddenly, building apps feels smoother, quicker, and honestly, kind of fun.

Why Modern Web Devs Love ClojureScript (Key Benefits) 

Why Modern Web Devs Love ClojureScript (Key Benefits) 

REPL Hot‑Reloading With shadow‑cljs For Instant Iteration  

There is nothing quite like seeing the code change in real time in a live environment. With ClojureScript’s REPL (Read‑Eval‑Print Loop), just type and watch the results show up right in the browser. shadow-cljs takes it up a notch- no more waiting for builds or hitting refresh over and over. Once code changes are made, they are available instantly. Fast feedback keeps developers in the flow and helps to get more done.

Functional Immutability Boosts React Performance via Reagent  

Now, let’s talk about performance. Reagent wraps around React but uses ClojureScript’s immutable data. Data never gets changed in place, so React only updates what’s actually new. That keeps things fast and predictable. This reduces bugs, keeps apps running smoothly, and lets developers focus on building features rather than debugging.

core.async For Cleaner, Callback‑Free async Flows  

Async code in JavaScript can get messy fast, making nested functions hard to read and maintain. core.async in ClojureScript simplifies that. Developers can use channels and lightweight threads, so the code appears simple while running asynchronously behind the scenes. Event handling, API calls, background tasks- suddenly, they are not such a headache.

A Modern Toolkit That Actually Makes Scalable Web Apps  

Put all these features together: live iteration with REPL and shadow‑cljs, predictable rendering with immutable data, and simplified async flows with core.async. Developers get a setup that helps move fast and avoid the typical issues that come with larger JavaScript apps. ClojureScript simplifies scaling, letting developers build more and fix less.

Top ClojureScript Tools and Libraries 2026

Top ClojureScript Tools and Libraries 2026

Looking ahead to 2026, these libraries are really powering ClojureScript- making it possible to build apps that are fast, reliable, and ready to scale.

Tooling & Build

  • Shadow-cljs: It works seamlessly with npm, gives a live REPL, and provides extremely fast hot-reload. Development just feels smoother and quicker.
  • Figwheel: It has remained a key feature for live reload and interactive dev for years. Shadow-CLJS is more common now, but plenty of projects still stick with Figwheel.

UI & Rendering

  • Reagent: A slim React wrapper that allows writing UI in Hiccup syntax. Because it is based on immutable data, UI updates are efficient, and issues are less likely to occur.
  • Rum: Another React wrapper that keeps things simple. If anyone is working on a smaller project or just wants the minimum layer on top of React, Rum is a good choice.
  • Hoplon: A library that helps to build reactive UIs with a spreadsheet-like model. Its “cells” update automatically, so making interactive apps feels more natural.

State Management & App Structure

  • Re-frame: It is a predictable state management library that organizes app state. Inspired by Redux, it makes data flow clear and helps to keep even big, complex apps manageable.
  • Keechma: It provides a structured app. It handles lifecycle and routing and integrates well with Reagent and re-frame. If the project is large, Keechma keeps things organized.

Data & Persistence

  • Datascript: An immutable database right in the browser. It supports Datomic-style queries, making the local state both powerful and flexible.
  • Fulcro: A full-stack framework for sharing code between the client and the server. It includes built-in GraphQL support and a data-driven design, making scaling straightforward.

Styling & Assets

  • Garden: With the Garden library, developers write their CSS as ClojureScript data. That makes styles easy to generate, reuse, and maintain. 

ClojureScript vs JavaScript/React: Quick Comparison 

FeatureClojureScript JavaScript/React 
State ManagementAtoms, immutable by default, re‑frameMutable by default, Redux/Context for control
PerformanceDead‑code removal, small bundles, efficient re‑rendersReact’s virtual DOM helps, but mutable state adds complexity
DXLive REPL experimentationConsole debugging
Full‑StackShare code with Clojure backendLanguage silos
Syntax & StyleFunctional, Lisp‑based, Hiccup for UIMix of imperative + functional, JSX for UI
Toolingshadow‑cljs, FigwheelWebpack, Vite, CRA
Async Handlingcore.async channels Promises, async/await, callbacks
Learning CurveSteeper (Lisp syntax, functional mindset)Easier entry, widely taught, and documented
Community & AdoptionSmaller but focused (finance, data apps, e‑commerce)Huge global adoption, dominant in web dev
StylingGarden (CSS as data)CSS‑in‑JS, styled‑components, plain CSS
Best FitComplex, scalable apps needing reliabilityGeneral apps, startups, fast onboarding

Real‑World ClojureScript Success Stories

Nubank  

Nubank stands out as one of Latin America’s largest fintechs, and it uses ClojureScript to power its user interfaces. Their teams rely on immutability and Reagent to build apps that serve millions of people. This setup keeps everything predictable and reliable- something absolutely needed in finance.

Reagent in Practice  

Apps built with Reagent just run smoother than typical React apps. Thanks to immutable data, updates are quick and reduce bugs caused by changing state. For many teams, Reagent is the go-to option for speed and stability without the headache.

Startups and Enterprises  

It is not just the giants using ClojureScript. Startups and big companies both use it to scale their apps while keeping things simple. With libraries like re-frame, state management gets easier, and tools like shadow-cljs let teams move fast. That combo allows it to grow without losing control.

Flexiana’s Success Stories  

Flexiana, a global consultancy, has delivered ClojureScript projects across different industries. We have built video consultation platforms, worked with graph databases, and co-developed SaaS products. By pairing ClojureScript with Polylith architecture, we keep systems modular and stable- even as things expand.

Why These Stories Matter

  • Nubank demonstrates that ClojureScript can handle millions of users.  
  • Reagent gives apps a real performance boost over plain React.  
  • Startups, big companies, and consultancies like Flexiana use it for actual products, not just side projects.  
  • Immutable data and predictable state cut bugs and simplify maintenance. 

Real-world businesses use ClojureScript, demonstrating its practicality.

Future of ClojureScript: Why Now in 2026

ClojureScript is no longer a mystery. By 2026, it became a stable choice for teams. It also supports scaling up while providing the right modern tools for the job.

Growing npm interop and GPU/web Tech Support  

Now, getting npm packages to play nice is easy. It helps capture what is needed and integrate it directly into the JavaScript workflow. Plus, with GPU support and WebAssembly, heavy tasks- like graphics, simulations, and data‑intensive apps run right in the browser. No more waiting around.

Rising Adoption of AI‑Driven Frontends  

AI is not just a buzzword these days; it is supported into almost every interface. ClojureScript’s focus on immutability and functional programming keeps things predictable, even when the state gets complex. That kind of reliability is huge when building smart, scalable frontends. 

Community Growth  

The community is stronger than ever. There are more libraries, tutorials, and frameworks available. New folks can learn without getting lost. Experienced teams can create complex structures. Polylith keeps large projects manageable and well-organized. 

So, Why Now  

In 2026, ClojureScript combines functional programming with modern web tech. It also connects naturally to AI design. It is now a proven choice for teams that need reliable performance and durable systems.

ClojureScript Tutorial: Build the First App

Let’s build a simple interactive app with ClojureScript, shadow‑cljs, and Reagent. It helps to get a real, working counter that updates in real time with a click.

Step 1: Set up shadow‑cljs

First, install shadow-cljs. This tool integrates ClojureScript with npm packages, runs the REPL, and supports hot reloading. 

Make sure Node.js and npm are installed. Then add shadow‑cljs to the project folder and run:

Set up the project: add a src folder for code and a shadow-cljs.edn file for builds.

Step 2: Create a Reagent Counter

Reagent is a ClojureScript wrapper for React that creates a UI with Hiccup syntax, not JSX.

Add Reagent to the dependencies in shadow-cljs.edn.

You can run the code locally by cloning the repository cljs_hot_reload_demo

Step 3: Run with REPL Hot‑Reloading

Start shadow‑cljs with:

Open the REPL and connect it to the running app.

Shadow-CLJS now instantly reloads everything in the browser anytime changes are made to the code. There is no need to refresh to view the changes right away.

What Results Achieved?

  • The developer just made a live update by adding a header and changing its color.
  • The developer set up a project that is ready to scale.
  • Additionally, the developer is working in a flow that combines the strength of the React ecosystem with the functional approach of ClojureScript. 

ClojureScript FAQs for JS Devs

ClojureScript vs React?  

Reagent builds on React. It introduces functional immutability and a cleaner, shorter syntax, making component development simpler and reducing bugs.

Learning curve?  

If a developer already knows JavaScript and React, there is no need to relearn everything. If developers are fans of functional programming, they will feel at home.

How does state management work?  

State remains immutable and is managed with atoms. Tools like re‑frame make complex state easier to manage and debug than typical mutable JS.

Can I use npm packages?  

Absolutely. With shadow-cljs, developers just pull in npm packages like they normally would. ClojureScript integrates seamlessly with the JS ecosystem.

What about tooling?  

shadow-cljs offers hot‑reloading, a live REPL, and smooth JavaScript interop- built for fast, flexible development.

Is performance an issue?  

Not at all. ClojureScript compiles down to efficient JavaScript. Paired with React or Reagent, the apps run just as quickly as plain JS ones- sometimes even faster.

How big is the community? 

The community is smaller than JavaScript’s but active, with numerous libraries, guides, and frameworks for developers.

Can I mix ClojureScript with existing JS code?  

Yep. Interop is easy. Developers can call JS functions from ClojureScript, and they can make their ClojureScript code available to JavaScript too.

Why choose ClojureScript over plain JS?  

Developers get immutable data and functional design. They also benefit from clear syntax. This reduces bugs and makes apps more reliable. It also keeps the code easier to manage.

Wrapping Up

By 2026, ClojureScript will be mainstream- a proven way to build scalable web apps. It provides instant feedback via REPL hot-reloading, so there is no need to wait for changes to appear. Immutable data keeps the app’s state under control, and that means fewer bugs to chase. 

core.async? It just makes async work less of a headache. Plus, the ecosystem is stacked: ShadowCLJS, Reagent, re-frame, and Fulcro all help build interactive UIs and enable front-to-back code reuse.

Developers can see the impact. Nubank, one of the largest fintech companies in Latin America, relies on ClojureScript to serve millions. Flexiana works with clients worldwide, using ClojureScript across healthcare and SaaS. Startups and large companies rely on us to grow without added complexity.

ClojureScript gives teams what they need: speed, reliability, and easier-to-maintain code. It is not just a passing trend. It is here to stay, and it is a smart choice for anyone who wants their apps to last.

Facing challenges with big datasets? Flexiana’s ClojureScript experts ensure smooth scaling.

The post ClojureScript Guide: Why Modern Devs Need It Now (2026 Edition) appeared first on Flexiana.

Permalink

Python to Wisp: The Lisp That Stole Python's Indentation

Python to Wisp: The Lisp That Stole Python's Indentation

Every Lisp programmer has heard the complaint: "too many parentheses." Every Python programmer has heard the praise: "the indentation makes it readable." Wisp asks a dangerous question: what if we put them together?

Wisp is an indentation-sensitive syntax for Scheme — specifically GNU Guile Scheme — standardized as SRFI-119. It takes the structure of Lisp and expresses it through indentation, the way Python does. The outer parentheses vanish. What remains is code that looks strangely like Python but thinks like Lisp — with macros, tail-call optimization, exact arithmetic, and homoiconicity.

This article walks through every major topic in the official Python tutorial and shows how Wisp approaches the same concept. Two languages, both indented, separated by philosophy.

1. Whetting Your Appetite

Python sells itself on readability, rapid prototyping, and an enormous ecosystem. It's the default choice for scripts, web backends, data science, and teaching.

Wisp sells itself on a different proposition: the readability of Python's visual structure combined with the raw power of Scheme. Scheme is one of the most theoretically clean programming languages ever designed — tail-call optimization, first-class continuations, hygienic macros — but its parenthesized syntax scares newcomers away. Wisp removes that barrier.

Wisp runs on GNU Guile, a mature Scheme implementation that ships with many GNU/Linux distributions. It gives you access to Guile's full ecosystem: real OS threads (no GIL), arbitrary-precision arithmetic, a sophisticated module system, and the ability to reshape the language through macros.

The trade-off? Python has PyPI. Wisp has the Guile ecosystem and whatever you can call via FFI. This isn't a contest for library count — it's a contest for expressiveness per line.

2. The Interpreter / The REPL

Python:

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

Wisp:

> + 2 2
4
> display "Hello, world!"
Hello, world!

To use Wisp interactively, launch guile and switch languages with ,L wisp. Or run scripts directly: guile --language=wisp -s hello.w.

The syntax is prefix notation — the function comes first, then its arguments — but without the outer parentheses that traditional Scheme requires. The line display "Hello, world!" is equivalent to Scheme's (display "Hello, world!"). Indentation replaces the parens.

3. An Informal Introduction

Numbers

Python:

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

Wisp:

/ 17 3            ; 17/3 — an exact rational!
quotient 17 3     ; 5
remainder 17 3    ; 2
expt 5 2          ; 25

The first difference is striking. (/ 17 3) in Scheme returns 17/3 — an exact rational number, not a floating-point approximation. Scheme distinguishes exact and inexact numbers at the type level. If you want the float, you ask for it explicitly: (exact->inexact (/ 17 3)) gives 5.666....

Operators take multiple arguments naturally:

+ 1 2 3 4 5       ; 15
* 2 3 4            ; 24
< 1 2 3 4          ; #t (chained comparison)

And Scheme gives you arbitrary-precision integers for free. No int vs long distinction, no overflow:

expt 2 1000        ; a 302-digit number, exactly

Strings

Python:

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

Wisp:

define word "Wisp"
string-ref word 0       ; #\W (a character)
substring word 0 2      ; "Wi"
string-length word       ; 4
format #f "Hello, ~a!" word  ; "Hello, Wisp!"

Scheme strings are mutable (unlike Python's), though idiomatic Scheme treats them as values. The format function uses ~a as a placeholder — #f means "return a string" and #t means "print directly":

format #t "Hello, ~a!\n" word   ; prints: Hello, Wisp!

Lists

Python:

squares = [1, 4, 9, 16, 25]
squares[0]              # 1
squares + [36, 49]      # [1, 4, 9, 16, 25, 36, 49]
squares.append(36)      # mutates!

Wisp:

define squares '(1 4 9 16 25)
list-ref squares 0              ; 1
append squares '(36 49)         ; (1 4 9 16 25 36 49)

Scheme lists are linked lists — immutable by convention, efficient at the head. append returns a new list without modifying the original. There's no .append mutation. If you want indexed access, Scheme has vectors:

define sq #(1 4 9 16 25)
vector-ref sq 0                 ; 1

The '(1 4 9 16 25) is a quoted list — the quote ' prevents Scheme from trying to call 1 as a function.

4. Control Flow

if / cond

Python:

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

Wisp:

cond
  : < x 0
    display "Negative"
  : = x 0
    display "Zero"
  else
    display "Positive"

In Wisp, cond handles multi-branch conditionals. Each branch starts with a : (which creates the nested parentheses Scheme expects). The else clause catches everything.

The simple if takes a condition, a then-branch, and an else-branch:

if : < x 0
  display "Negative"
  display "Non-negative"

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

define label
  if : < x 0
    . "negative"
    . "non-negative"

The . (dot) prevents the string from being treated as a function call — it marks a bare value rather than an application.

for loops

Python:

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

Wisp:

for-each
  lambda : word
    display word
    display " "
    display : string-length word
    newline
  '("cat" "window" "defenestrate")

Scheme doesn't have a built-in for loop — iteration is done through for-each (for side effects) or map (for transformation). The lambda defines an anonymous function applied to each element.

Or using a named let (Scheme's idiomatic loop):

let loop
  : words '("cat" "window" "defenestrate")
  when : not : null? words
    display : car words
    display " "
    display : string-length : car words
    newline
    loop : cdr words

This is recursion that looks like a loop. car gets the first element, cdr gets the rest, and loop calls itself with the remaining list. Guile guarantees tail-call optimization, so this uses constant stack space — unlike Python, which would hit a recursion limit.

range

Python:

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

Wisp:

use-modules : srfi srfi-1

iota 5 0 2             ; (0 2 4 6 8)

Guile's iota from SRFI-1 generates numeric sequences. The arguments are count, start, and step — slightly different from Python's start, stop, step.

while

Python:

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

Wisp:

let loop : : a 0
             b 1
  when : < a 10
    display a
    newline
    loop b {a + b}

There's no while keyword here — it's a named let that recurses. The {a + b} uses Wisp's curly-brace infix syntax (from SRFI-105), which lets you write math the familiar way. Without it, you'd write (+ a b).

Pattern Matching

Python 3.10+:

match command:
    case "quit":
        quit_game()
    case "help":
        show_help()
    case _:
        print("Unknown")

Wisp (using Guile's match):

use-modules : ice-9 match

match command
  : "quit"
    quit-game
  : "help"
    show-help
  : _
    display "Unknown"

Guile's match supports destructuring, guards, and nested patterns — similar to Python's structural pattern matching but available since long before Python 3.10.

Functions

Python:

def factorial(n):
    """Return the factorial of n."""
    if n == 0:
        return 1
    return n * factorial(n - 1)

Wisp:

define : factorial n
  "Return the factorial of n."
  if : = n 0
    . 1
    * n : factorial {n - 1}

Functions are defined with define. The : factorial n creates (factorial n) — a function named factorial taking one argument. The docstring goes right after the parameter list. The last expression is the return value.

The Wisp version also benefits from tail-call optimization if rewritten in tail-recursive style:

define : factorial n
  let loop : : i n
               acc 1
    if : = i 0
      . acc
      loop {i - 1} {acc * i}

This handles (factorial 100000) without breaking a sweat. Python would need sys.setrecursionlimit or an iterative rewrite.

Default and Keyword Arguments

Python:

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

Wisp:

use-modules : ice-9 optargs

define* : greet name #:optional (greeting "Hello")
  format #t "~a, ~a!\n" greeting name

Guile uses define* (from ice-9 optargs) for optional and keyword arguments:

define* : connect host #:key (port 8080) (timeout 30)
  format #t "Connecting to ~a:~a (timeout ~a)\n" host port timeout

connect "example.com" #:port 443
; => Connecting to example.com:443 (timeout 30)

#:optional for positional defaults, #:key for keyword arguments. More explicit than Python's unified syntax, but equally capable.

Lambda Expressions

Python:

double = lambda x: x * 2
sorted(words, key=lambda w: len(w))

Wisp:

define double
  lambda : x
    * x 2

sort words
  lambda : a b
    < (string-length a) (string-length b)

In Wisp, lambda is a full block — no single-expression restriction like Python's lambda. The indented body can contain anything.

5. Data Structures

Lists (Linked Lists)

Scheme lists are cons-cell linked lists — the fundamental data structure of all Lisps:

define fruits '("apple" "banana" "cherry")
car fruits                    ; "apple" (first)
cdr fruits                    ; ("banana" "cherry") (rest)
cons "mango" fruits           ; ("mango" "apple" "banana" "cherry")
length fruits                 ; 3
list-ref fruits 1             ; "banana"
Operation Python Wisp
First element lst[0] car lst
Rest of list lst[1:] cdr lst
Prepend [x] + lst cons x lst
Append lst + [x] append lst (list x)
Length len(lst) length lst
Nth element lst[n] list-ref lst n

Vectors (Arrays)

For indexed access, Scheme has vectors:

define v #(10 20 30 40 50)
vector-ref v 2               ; 30
vector-length v               ; 5

Hash Tables (Dictionaries)

Python:

tel = {"jack": 4098, "sape": 4139}
tel["guido"] = 4127
del tel["sape"]

Wisp:

define tel : make-hash-table

hashq-set! tel 'jack 4098
hashq-set! tel 'sape 4139
hashq-set! tel 'guido 4127
hashq-remove! tel 'sape

hashq-ref tel 'jack          ; 4098

Hash tables in Guile use explicit functions rather than syntax. It's more verbose, but Scheme compensates with association lists for small mappings:

define tel
  ' : (jack . 4098)
      (sape . 4139)

assoc 'jack tel               ; (jack . 4098)

Association lists are simple, immutable, and idiomatic for small key-value collections.

Sets

Guile doesn't have a built-in set type, but SRFI-113 provides them, or you can use sorted lists with lset- operations from SRFI-1:

use-modules : srfi srfi-1

define a '(1 2 3 4)
define b '(3 4 5 6)

lset-difference equal? a b     ; (1 2)
lset-union equal? a b          ; (1 2 3 4 5 6)
lset-intersection equal? a b   ; (3 4)

List Comprehensions

Python:

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

Wisp:

use-modules : srfi srfi-42

list-ec : : i (index 10)
  if : = 0 : remainder i 2
  expt i 2
; => (0 4 16 36 64)

SRFI-42 provides eager comprehensions. The : lines create the nested structure that Scheme expects.

Or idiomatically with map and filter:

map
  lambda : x
    expt x 2
  filter even? : iota 10

Looping Techniques

Python:

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

Wisp:

let loop : : i 0
             words '("tic" "tac" "toe")
  when : not : null? words
    format #t "~a ~a\n" i : car words
    loop {i + 1} : cdr words

Or using SRFI-42:

do-ec : : i (index 3)
  format #t "~a ~a\n" i
    list-ref '("tic" "tac" "toe") i

6. Modules

Importing

Python:

import math
from os.path import join
import json as j

Wisp:

use-modules : ice-9 regex

use-modules
  : srfi srfi-1
    #:select : iota fold

use-modules
  : ice-9 popen
    #:prefix popen-

Guile's module system uses use-modules. The #:select option imports specific symbols, and #:prefix namespaces them — similar to Python's from X import Y and import X as Y.

Defining Modules

Python:

# mymodule.py
def greet(name):
    return f"Hello, {name}!"

Wisp:

define-module : myproject mymodule
  . #:export : greet

define : greet name
  format #f "Hello, ~a!" name

The #:export clause explicitly lists what the module makes public — like Python's __all__ but mandatory and standard.

The Script Entry Point

Python:

if __name__ == "__main__":
    main()

Wisp scripts use a shell header that invokes Guile with the right flags:

#!/usr/bin/env bash
exec guile -L . -x .w --language=wisp -e main -s "$0" "$@"
!#

define : main args
  display "Hello from the command line!\n"

The -e main flag tells Guile to call the main function with command-line arguments.

7. Input and Output

String Formatting

Python:

name = "World"
f"Hello, {name}!"
"{:.2f}".format(3.14159)

Wisp:

define name "World"
format #f "Hello, ~a!" name           ; "Hello, World!"
format #f "~,2f" 3.14159              ; "3.14"

Guile's format uses tilde-based directives: ~a for display, ~s for write (with quotes), ~d for integer, ~f for float, ~% for newline. It's closer to Common Lisp's format than to Python's f-strings.

File I/O

Python:

with open("data.txt", encoding="utf-8") as f:
    content = f.read()

with open("output.txt", "w") as f:
    f.write("Hello\n")

Wisp:

use-modules : ice-9 textual-ports

; Read entire file
define content
  call-with-input-file "data.txt" get-string-all

; Write to file
call-with-output-file "output.txt"
  lambda : port
    display "Hello\n" port

Or using with-input-from-file for a more Python-with-like pattern:

with-input-from-file "data.txt"
  lambda ()
    let loop : : line (read-line)
      when : not : eof-object? line
        display line
        newline
        loop : read-line

Scheme's port system is lower-level than Python's file objects but equally capable. Every I/O function accepts an optional port argument — display "hello" port writes to a specific destination.

JSON

Guile has a JSON module:

use-modules : json

define data
  json-string->scm "{\"name\": \"Ada\"}"
; => (("name" . "Ada"))

scm->json-string '((name . "Ada"))
; => "{\"name\":\"Ada\"}"

8. Errors and Exceptions

Try / Catch

Python:

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

Wisp:

catch #t
  lambda ()
    ; try body
    define x
      string->number : read-line
    when : not x
      error "Invalid number"
  lambda : key . args
    ; catch handler
    format #t "Error: ~a ~a\n" key args

display "Done\n"

Guile uses catch and throw (or the newer with-exception-handler / raise-exception). The handler is a lambda that receives the error key and arguments.

The more modern style:

use-modules : ice-9 exceptions

with-exception-handler
  lambda : exn
    format #t "Error: ~a\n" : exception-message exn
  lambda ()
    string->number "not-a-number"
  . #:unwind? #t

Raising Exceptions

Python:

raise ValueError("something went wrong")

Wisp:

error "something went wrong"

Or with structured keys:

throw 'value-error "something went wrong"

Custom Error Types

Python creates exception classes. Guile uses symbol keys:

throw 'insufficient-funds "Need more money"
  . balance amount

Or with the newer condition system from R7RS:

use-modules : ice-9 exceptions

define-exception-type &insufficient-funds &error
  make-insufficient-funds
  insufficient-funds?
  : balance insufficient-funds-balance
    amount insufficient-funds-amount

More ceremony than Python's class hierarchy, but also more structured — each field is explicitly named and accessible.

9. Classes and Objects

This is where the languages diverge most sharply.

Python's Object-Oriented Approach

class Dog:
    kind = "canine"

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

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

rex = Dog("Rex")
rex.speak()

Wisp's Approach: GOOPS

Guile has GOOPS (GNU Object-Oriented Programming System), a powerful CLOS-style object system:

use-modules : oop goops

define-class <dog> ()
  kind
    . #:init-value "canine"
    . #:getter dog-kind
  name
    . #:init-keyword #:name
    . #:getter dog-name

define-method : speak (dog <dog>)
  format #f "~a says woof!" : dog-name dog

define rex : make <dog> #:name "Rex"
speak rex                ; "Rex says woof!"

GOOPS supports multiple inheritance, multiple dispatch (methods dispatch on all argument types, not just the first), metaclasses, and method combinations. It's more powerful than Python's object system, but also more verbose.

Inheritance

Python:

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

Wisp:

define-class <puppy> (<dog>)

define-method : speak (dog <puppy>)
  format #f "~a says yip!" : dog-name dog

The Alternative: Just Use Data

Many Scheme programmers avoid GOOPS entirely and use plain data structures with functions — similar to Clojure's philosophy:

define : make-dog name
  ' : (kind . "canine")
      (name . ,name)

define : dog-speak dog
  format #f "~a says woof!" : assoc-ref dog 'name

define rex : make-dog "Rex"
dog-speak rex

This is simpler and often sufficient.

10. The Standard Library

Python's Batteries vs. Guile's Foundations

Python ships with modules for everything. Guile ships with fewer but more fundamental tools:

Python Module Guile Equivalent
os POSIX bindings (built-in)
sys (ice-9 command-line)
re (ice-9 regex)
math Built-in exact arithmetic + (ice-9 math)
json (json)
threading (ice-9 threads) — real OS threads, no GIL!
unittest SRFI-64
datetime SRFI-19
argparse (ice-9 getopt-long)
collections SRFIs (1, 69, 113, etc.)
http (web client), (web server)
sqlite3 Guile-DBI

Regex

use-modules : ice-9 regex

define m : string-match "[0-9]+" "hello 42 world"
match:substring m              ; "42"

Threading — No GIL

This is Guile's secret weapon. Where Python has the GIL limiting true parallelism, Guile has real POSIX threads:

use-modules : ice-9 threads

call-with-new-thread
  lambda ()
    display "Hello from another thread!\n"

par-map
  lambda : x
    * x x
  iota 10
; => (0 1 4 9 16 25 36 49 64 81)

par-map is parallel map — it distributes work across OS threads. No multiprocessing workaround, no async/await complexity. Just threads.

Web Server in 10 Lines

use-modules : web server

run-server
  lambda : request request-body
    values
      ' : (content-type . (text/plain))
      . "Hello, World!"

Guile ships with a built-in HTTP server and client. No framework needed for basic use.

11. Advanced Standard Library

Output Formatting

use-modules : ice-9 pretty-print

pretty-print
  ' : (name . "Ada")
      (languages . ("Python" "Wisp" "Scheme"))

Multi-threading (Continued)

use-modules : ice-9 threads

define mutex : make-mutex
define counter 0

define : increment
  lock-mutex mutex
  set! counter {counter + 1}
  unlock-mutex mutex

; Spawn 100 threads
let loop : : i 100
  when : > i 0
    call-with-new-thread increment
    loop {i - 1}

Exact Decimal Arithmetic

Python needs the decimal module. Scheme has exact rationals built-in:

+ 1/10 2/10               ; 3/10 (exact!)
= (+ 1/10 2/10) 3/10      ; #t
* 7/10 105/100             ; 147/200 (exact!)

No Decimal class, no imports. Exact fractions are a primitive type.

12. Virtual Environments and Packages

Python has venv and pip. Guile has a different model.

Installation:

# Guile is often pre-installed on GNU/Linux
sudo apt install guile-3.0

# Wisp
sudo apt install guile-wisp
# or from source

Dependencies:
Guile uses a load-path model rather than per-project virtual environments:

export GUILE_LOAD_PATH="/path/to/library:$GUILE_LOAD_PATH"
guile --language=wisp -s myapp.w

For project-specific dependencies, the community uses GNU Guix (a functional package manager) or manual load-path management. It's less ergonomic than pip install, but aligns with the GNU ecosystem's philosophy.

There's no PyPI equivalent. Libraries are distributed through GNU Guix, OS packages, or directly as source. This is Wisp's weakest point compared to Python.

13. Floating-Point Arithmetic

Python:

>>> 0.1 + 0.2 == 0.3
False

Wisp:

= {0.1 + 0.2} 0.3             ; #f (same problem with floats!)

Same IEEE 754, same surprises. But Scheme has an escape hatch Python doesn't — exact rationals are a first-class type, not a library:

= {1/10 + 2/10} 3/10          ; #t (exact!)
exact->inexact 1/3              ; 0.3333333333333333
inexact->exact 0.1              ; 3602879701896397/36028797018963968

That last line reveals the actual value stored for 0.1 — a ratio of two large integers. Scheme lets you move freely between exact and inexact worlds.

14. Interactive Editing

Python's REPL supports readline history and tab completion.

Guile's REPL offers similar features plus meta-commands:

,help              ; show all REPL commands
,describe display  ; show documentation
,time (fib 30)     ; benchmark an expression
,profile (fib 30)  ; profile an expression
,L wisp            ; switch to Wisp syntax
,L scheme          ; switch back to Scheme

You can switch between Wisp and Scheme syntax in the same session. This is useful for learning — write in Wisp, see what Scheme it corresponds to.

15. The Secret Weapon: Macros

Like all Lisps, Wisp supports macros — compile-time code transformations. But because Wisp code looks like indented pseudocode, Wisp macros are unusually readable for Lisp macros.

Example: A Python-style for-in loop

define-syntax-rule : for-in var lst body ...
  for-each
    lambda : var
      body ...
    lst

; Now use it:
for-in word '("cat" "window" "defenestrate")
  display word
  newline

You just added a for-in construct to the language. It's not a function that takes a callback — it's real syntax that the compiler expands before execution.

Example: Unless

define-syntax-rule : unless condition body ...
  when : not condition
    body ...

unless : = 1 2
  display "Math still works\n"

Example: Time-it

define-syntax-rule : time-it body ...
  let : : start (current-time)
    body ...
    format #t "Elapsed: ~a seconds\n"
      - (current-time) start

time-it
  let loop : : i 1000000
    when : > i 0
      loop {i - 1}

Why This Matters

In Python, if you need a new control structure, you write a function with callbacks or a context manager — a workaround. In Wisp, you write a macro that generates the exact code you want, with zero runtime overhead. The result is indistinguishable from a built-in language feature.

This is the deep reason Lisp syntax exists: when code and data have the same structure, programs that write programs become natural.

The Big Picture

Dimension Python Wisp
Syntax Indentation + keywords Indentation + prefix notation
Paradigm Multi-paradigm (imperative-first) Multi-paradigm (functional-first)
Type System Dynamic Dynamic
Mutability Mutable by default Immutable by convention
Tail Calls No optimization (recursion limit) Guaranteed optimization
Arithmetic Floats by default Exact rationals by default
Threading GIL limits parallelism Real OS threads
Macros No Hygienic macros
OOP Classes + inheritance GOOPS (multiple dispatch) or plain data
Ecosystem Massive (PyPI) Small (Guile + GNU Guix)
Runtime CPython GNU Guile (JIT-compiled Scheme)
Best For Scripts, ML/AI, web, teaching Systems, DSLs, concurrency, extensible programs

Closing Thoughts

Wisp is not trying to replace Python. It's trying to answer a question: can Lisp be approachable?

The answer is yes. Wisp code reads like indented pseudocode. You can show it to someone who has never programmed and they'll follow the structure. But underneath that gentle surface lies one of the most powerful programming models ever devised — one where functions are data, data is code, and the language reshapes itself to fit your problem.

The ecosystem gap is real. You won't find a Wisp equivalent of NumPy or Django. But for systems programming, scripting, language design, concurrent servers, or any domain where you want to think differently about code — Wisp offers something Python cannot: a Lisp you can read at a glance.

display "Welcome to Wisp.\n"
display "Where indentation meets imagination.\n"

Get started: install Guile, install Wisp, and type ,L wisp in the Guile REPL. The parentheses are gone. The power remains.

This article was written as a companion to the official Python tutorial. Every section maps to a chapter in that tutorial, showing the same concepts through Wisp's indentation-sensitive Lisp lens. For more on Wisp, visit draketo.de/software/wisp.

Permalink

Python to Clojure: A Gentle Guide for Pythonistas

Python to Clojure: A Gentle Guide for Pythonistas

Python is the world's most popular general-purpose language. Clojure is a quiet powerhouse — a modern Lisp on the JVM that has earned fierce loyalty among developers who discover it. Both languages prize simplicity, but they mean very different things by the word.

This article walks through every major topic in the official Python tutorial and shows how Clojure approaches the same idea. Whether you're a Pythonista curious about functional programming or a polyglot looking for a side-by-side reference, this guide is for you.

1. Whetting Your Appetite

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

Clojure shares the same REPL-driven culture, but takes a more opinionated stance: data is the universal interface. Where Python says "everything is an object," Clojure says "everything is data." Clojure runs on the JVM (and in the browser via ClojureScript), giving it access to the entire Java ecosystem — a different kind of "batteries included."

Both languages let you sit down and be productive in minutes. The difference is in what they optimize for: Python optimizes for readability of imperative code; Clojure optimizes for simplicity of data transformation.

2. The Interpreter / The REPL

Python has an interactive interpreter invoked with python:

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

Clojure has the REPL (Read-Eval-Print Loop), which serves the same purpose but is even more central to the workflow. Most Clojure developers keep a REPL connected to their editor at all times:

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

The parentheses aren't noise — they're the syntax. Every expression is a list where the first element is the function. This uniformity is what makes Lisp macros possible, and it takes about a day to stop noticing the parens.

3. An Informal Introduction

Numbers

Python:

>>> 17 / 3      # 5.666...
>>> 17 // 3     # 5 (floor division)
>>> 17 % 3      # 2
>>> 5 ** 2      # 25

Clojure:

(/ 17 3)       ;=> 17/3  (a rational! no precision lost)
(quot 17 3)    ;=> 5
(rem 17 3)     ;=> 2
(Math/pow 5 2) ;=> 25.0

Right away, a philosophical difference appears. Clojure gives you a ratio (17/3) by default rather than silently losing precision. Clojure also has arbitrary-precision integers out of the box — no special int vs long distinction to worry about.

Strings

Python:

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

Clojure:

(def word "Clojure")
(get word 0)          ;=> \C  (a character)
(subs word 0 2)       ;=> "Cl"
(count word)          ;=> 7
(str "Hello, " word "!")

Both treat strings as immutable. In Python, strings are sequences of characters; in Clojure, strings are Java strings, but you can also treat them as sequences of characters when needed via seq.

Lists

Python:

squares = [1, 4, 9, 16, 25]
squares[0]              # 1
squares + [36, 49]      # [1, 4, 9, 16, 25, 36, 49]
squares.append(36)      # mutates!

Clojure:

(def squares [1 4 9 16 25])
(get squares 0)              ;=> 1
(conj squares 36)            ;=> [1 4 9 16 25 36] (new vector!)
(into squares [36 49])       ;=> [1 4 9 16 25 36 49]

This is the first big fork in the road. Python lists are mutable; Clojure vectors are persistent and immutable. conj doesn't change squares — it returns a new vector that efficiently shares structure with the old one. No defensive copying, no "did someone mutate my list?" bugs.

4. Control Flow

if / cond

Python:

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

Clojure:

(cond
  (neg? x) (println "Negative")
  (zero? x) (println "Zero")
  :else (println "Positive"))

In Clojure, if is an expression that returns a value (no need for a ternary operator). cond handles the multi-branch case.

for loops

Python:

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

Clojure:

(doseq [word ["cat" "window" "defenestrate"]]
  (println word (count word)))

But here's the thing — doseq is for side effects. When you want to transform data (which is most of the time), you use map, filter, or for (which is a list comprehension, not a loop):

(for [word ["cat" "window" "defenestrate"]]
  [word (count word)])
;=> (["cat" 3] ["window" 6] ["defenestrate" 13])

range

Python:

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

Clojure:

(range 0 10 2)  ;=> (0 2 4 6 8)

Clojure's range is lazy — it doesn't allocate memory for all elements upfront.

Pattern Matching

Python 3.10+:

match status:
    case 400:
        return "Bad request"
    case 401 | 403:
        return "Not allowed"
    case _:
        return "Something's wrong"

Clojure (with core.match):

(match status
  400 "Bad request"
  (:or 401 403) "Not allowed"
  :else "Something's wrong")

Clojure's pattern matching via core.match is a library, not built-in syntax — which is itself a philosophical statement. In Lisp, you can add any syntax you want via macros.

Functions

Python:

def fib(n):
    """Return Fibonacci series up to n."""
    a, b = 0, 1
    result = []
    while a < n:
        result.append(a)
        a, b = b, a + b
    return result

Clojure:

(defn fib [n]
  "Return Fibonacci series up to n."
  (->> [0 1]
       (iterate (fn [[a b]] [b (+ a b)]))
       (map first)
       (take-while #(< % n))))

The Clojure version reads as a pipeline: start with [0 1], repeatedly produce the next pair, extract the first element, and keep going while it's less than n. No mutation, no accumulator variable, no while loop. Just data flowing through transformations.

Default Arguments, *args, **kwargs

Python:

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

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

Clojure:

(defn greet
  ([name] (greet name "Hello"))
  ([name greeting] (println (str greeting ", " name "!"))))

(defn log [& args]
  (println args))

Clojure uses multi-arity functions for defaults and & args for variadics. For keyword arguments, the idiomatic approach is to pass a map:

(defn create-user [{:keys [name email role] :or {role "member"}}]
  {:name name :email email :role role})

(create-user {:name "Ada" :email "ada@example.com"})

Lambda Expressions

Python:

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

Clojure:

(def double #(* % 2))        ; reader macro shorthand
(sort-by second pairs)       ; or: (sort-by #(nth % 1) pairs)

In Clojure, anonymous functions are so common they have a shorthand: #(...) with % for the argument. Named functions and anonymous functions are treated identically — there's no second-class lambda.

5. Data Structures

Lists, Tuples, and Vectors

Python Clojure Properties
list[1, 2, 3] vector[1 2 3] Indexed, ordered
tuple(1, 2, 3) vector[1 2 3] (Clojure vectors are already immutable)
list'(1 2 3) Linked list, efficient head access

Python needs both lists (mutable) and tuples (immutable). Clojure needs only vectors (immutable, indexed) and lists (immutable, sequential). The mutability question simply doesn't arise.

Dictionaries / Maps

Python:

tel = {"jack": 4098, "sape": 4139}
tel["guido"] = 4127
del tel["sape"]
{x: x**2 for x in range(5)}

Clojure:

(def tel {"jack" 4098 "sape" 4139})
(assoc tel "guido" 4127)       ;=> new map with guido added
(dissoc tel "sape")            ;=> new map without sape
(into {} (map (fn [x] [x (* x x)]) (range 5)))

Again: assoc and dissoc return new maps. The original is untouched. Clojure maps can also use keywords as keys (:jack instead of "jack"), which double as accessor functions — a small but delightful ergonomic touch:

(def person {:name "Ada" :age 36})
(:name person)  ;=> "Ada"

Sets

Python:

a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
a - b            # {1, 2}
a | b            # {1, 2, 3, 4, 5, 6}
a & b            # {3, 4}

Clojure:

(require '[clojure.set :as set])
(def a #{1 2 3 4})
(def b #{3 4 5 6})
(set/difference a b)     ;=> #{1 2}
(set/union a b)          ;=> #{1 2 3 4 5 6}
(set/intersection a b)   ;=> #{3 4}

Virtually identical semantics, different syntax. Clojure sets are also immutable and can be used as functions: (a 3) returns 3 (truthy), (a 7) returns nil (falsy).

List Comprehensions

Python:

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

Clojure:

(for [x (range 10) :when (even? x)]
  (* x x))

Clojure's for is a list comprehension, not a loop. It supports :when for filtering, :let for local bindings, and multiple binding forms for nested iteration — all lazily evaluated.

Looping Techniques

Python:

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

for q, a in zip(questions, answers):
    print(q, a)

Clojure:

(doseq [[i v] (map-indexed vector ["tic" "tac" "toe"])]
  (println i v))

(doseq [[q a] (map vector questions answers)]
  (println q a))

Or more idiomatically, just use map to transform and print later:

(map-indexed (fn [i v] [i v]) ["tic" "tac" "toe"])
(map vector questions answers)

6. Modules and Namespaces

Python:

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

Clojure:

(require '[clojure.string :as str])
(require '[clojure.java.io :as io])
(import '[java.util Date])

Python organizes code into modules (files) and packages (directories with __init__.py). Clojure organizes code into namespaces — each file declares a namespace with ns, and dependencies are explicit:

(ns myapp.core
  (:require [clojure.string :as str]
            [cheshire.core :as json])
  (:import [java.time LocalDate]))

There's no from X import * equivalent in Clojure, and that's by design. Explicit is better than implicit — a principle Pythonistas already appreciate.

The if __name__ == "__main__" Pattern

Python:

if __name__ == "__main__":
    main()

Clojure doesn't need this pattern because the REPL workflow means you rarely "run a file." When you do need an entry point, you declare a -main function:

(defn -main [& args]
  (println "Hello from the command line!"))

7. Input and Output

String Formatting

Python:

name = "World"
f"Hello, {name}!"
"{:.2f}".format(3.14159)

Clojure:

(str "Hello, " name "!")            ; simple concatenation
(format "Hello, %s!" name)          ; printf-style
(format "%.2f" 3.14159)             ; "3.14"

Clojure uses Java's String.format under the hood. There's no f-string equivalent, but str for concatenation and format for templates cover the same ground.

File I/O

Python:

with open("data.txt", encoding="utf-8") as f:
    content = f.read()

with open("output.txt", "w", encoding="utf-8") as f:
    f.write("Hello\n")

Clojure:

(slurp "data.txt")                         ; read entire file
(spit "output.txt" "Hello\n")             ; write entire file

;; For line-by-line processing:
(with-open [rdr (clojure.java.io/reader "data.txt")]
  (doseq [line (line-seq rdr)]
    (println line)))

slurp and spit — arguably the best-named I/O functions in any language. For structured work, with-open mirrors Python's with statement.

JSON

Python:

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

Clojure (with cheshire or clojure.data.json):

(require '[cheshire.core :as json])
(json/parse-string "{\"name\": \"Ada\"}" true)  ;=> {:name "Ada"}
(json/generate-string {:name "Ada"})             ;=> "{\"name\":\"Ada\"}"

The true argument keywordizes the keys — JSON maps become idiomatic Clojure maps instantly.

8. Errors and Exceptions

Try / Catch

Python:

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

Clojure:

(try
  (Integer/parseInt (read-line))
  (catch NumberFormatException e
    (println "Invalid:" (.getMessage e)))
  (finally
    (println "Done")))

Since Clojure runs on the JVM, it catches Java exceptions. The structure is almost identical to Python's try/except/finally.

Raising Exceptions

Python:

raise ValueError("something went wrong")

Clojure:

(throw (ex-info "something went wrong" {:type :validation}))

ex-info is idiomatic Clojure — it creates an exception that carries a data map. Instead of defining custom exception classes, you attach structured data to a generic exception. This is the "data over classes" philosophy in action:

(try
  (throw (ex-info "bad input" {:field :email :value "not-an-email"}))
  (catch clojure.lang.ExceptionInfo e
    (println (ex-data e))))
;=> {:field :email, :value "not-an-email"}

Custom Exceptions

Python:

class InsufficientFunds(Exception):
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount

Clojure rarely defines custom exception classes. Instead:

(throw (ex-info "Insufficient funds"
         {:type :insufficient-funds
          :balance 100
          :amount 150}))

One generic mechanism, infinite data shapes. No class hierarchy to maintain.

9. Classes and Objects

This is where Python and Clojure diverge most dramatically.

Python's Object-Oriented Approach

class Dog:
    kind = "canine"             # class variable

    def __init__(self, name):
        self.name = name        # instance variable

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

rex = Dog("Rex")
rex.speak()  # "Rex says woof!"

Clojure's Data-Oriented Approach

(def rex {:kind "canine" :name "Rex"})

(defn speak [dog]
  (str (:name dog) " says woof!"))

(speak rex)  ;=> "Rex says woof!"

No class. No self. No constructor. Just a map and a function. The dog is its data.

Inheritance vs. Composition

Python:

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

class Dog(Animal):
    def speak(self):
        return "Woof!"

class Cat(Animal):
    def speak(self):
        return "Meow!"

Clojure uses multimethods or protocols for polymorphism:

;; Multimethod approach — dispatch on data
(defmulti speak :type)
(defmethod speak :dog [animal] "Woof!")
(defmethod speak :cat [animal] "Meow!")

(speak {:type :dog :name "Rex"})   ;=> "Woof!"
(speak {:type :cat :name "Whiskers"}) ;=> "Meow!"
;; Protocol approach — like interfaces
(defprotocol Speakable
  (speak [this]))

(defrecord Dog [name]
  Speakable
  (speak [this] (str name " says Woof!")))

(speak (->Dog "Rex"))  ;=> "Rex says Woof!"

Multimethods dispatch on any function of the arguments (not just type), making them more flexible than traditional OOP dispatch.

Iterators and Generators

Python:

class Reverse:
    def __init__(self, data):
        self.data = data
        self.index = len(data)

    def __iter__(self):
        return self

    def __next__(self):
        if self.index == 0:
            raise StopIteration
        self.index -= 1
        return self.data[self.index]
def reverse(data):
    for index in range(len(data) - 1, -1, -1):
        yield data[index]

Clojure:

(reverse "spam")  ;=> (\m \a \p \s)
(rseq [1 2 3 4])  ;=> (4 3 2 1)

Clojure's sequences are lazy iterators. There's no iterator protocol to implement — every collection already participates in the sequence abstraction. Need a custom lazy sequence? Use lazy-seq:

(defn countdown [n]
  (when (pos? n)
    (lazy-seq (cons n (countdown (dec n))))))

(countdown 5)  ;=> (5 4 3 2 1)

Generator Expressions

Python:

sum(x*x for x in range(10))

Clojure:

(reduce + (map #(* % %) (range 10)))
;; or with the threading macro:
(->> (range 10) (map #(* % %)) (reduce +))

The threading macro ->> reads like a Unix pipeline and is one of Clojure's most beloved features.

10. The Standard Library

Python's "Batteries Included"

Python ships with modules for OS interaction, file I/O, regex, math, dates, HTTP, email, testing, logging, threading, and much more — all in the standard library.

Clojure's Approach

Clojure's standard library is smaller but remarkably powerful for data manipulation. For everything else, you tap into:

  1. The entire Java ecosystem — need HTTP? Use java.net.http. Need dates? Use java.time. Need crypto? Use javax.crypto.
  2. Clojure community libraries — managed via deps.edn or Leiningen.
Python Module Clojure Equivalent
os, shutil clojure.java.io, Java NIO
re re-find, re-matches, re-seq (built-in)
math clojure.math (1.11+), java.lang.Math
datetime java.time (via tick library for ergonomics)
json clojure.data.json or cheshire
unittest clojure.test (built-in)
logging tools.logging + logback
threading core.async, future, pmap, agents
collections Built into the core (persistent data structures)
argparse tools.cli
sqlite3 next.jdbc + any JDBC driver

Concurrency — Where Clojure Truly Shines

Python's threading story involves the GIL and careful locking. Clojure was designed for concurrency from day one:

  • Atoms — uncoordinated, synchronous state updates
  • Refs — coordinated, transactional state (Software Transactional Memory)
  • Agents — asynchronous state updates
  • core.async — CSP-style channels (like Go's goroutines)
(def counter (atom 0))
(swap! counter inc)      ;=> 1  (thread-safe, no locks)
@counter                 ;=> 1

;; Process 1000 items in parallel:
(pmap expensive-fn (range 1000))

11. Advanced Standard Library

Output Formatting

Python: pprint, textwrap, locale
Clojure: clojure.pprint/pprint (built-in), Java's Locale

(require '[clojure.pprint :refer [pprint]])
(pprint {:a 1 :b {:c [1 2 3] :d "hello"}})

Templating

Python: string.Template
Clojure: clojure.core/format, or libraries like Selmer for HTML templating.

Multi-threading

Python:

import threading
t = threading.Thread(target=worker)
t.start()

Clojure:

(future (worker))  ; runs in a thread pool, returns a deref-able future
@(future (+ 1 2))  ;=> 3

Logging

Python: logging.warning("Watch out!")
Clojure:

(require '[clojure.tools.logging :as log])
(log/warn "Watch out!")

Decimal Arithmetic

Python: decimal.Decimal("0.1") + decimal.Decimal("0.2")
Clojure:

(+ 0.1M 0.2M)  ;=> 0.3M  (BigDecimal literal with M suffix)

Clojure has literal syntax for BigDecimals (M suffix) and BigIntegers (N suffix) — no imports needed.

12. Virtual Environments and Packages

Python uses venv and pip:

python -m venv myenv
source myenv/bin/activate
pip install requests
pip freeze > requirements.txt

Clojure uses deps.edn (official) or project.clj (Leiningen):

;; deps.edn
{:deps {org.clojure/clojure {:mvn/version "1.12.0"}
        cheshire/cheshire {:mvn/version "5.13.0"}}}
clj -M -m myapp.core  # run with dependencies resolved automatically

There's no virtual environment concept because dependencies are resolved per-project from the deps.edn file. Maven coordinates ensure reproducibility. No activate/deactivate dance.

13. Floating-Point Arithmetic

Both languages sit on top of IEEE 754 doubles:

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

Same hardware, same surprise. But Clojure offers escape hatches as first-class citizens:

;; Ratios — exact arithmetic, no precision loss
(+ 1/10 2/10)      ;=> 3/10
(= (+ 1/10 2/10) 3/10)  ;=> true

;; BigDecimals
(+ 0.1M 0.2M)      ;=> 0.3M

Clojure's ratio type means you can do exact fractional arithmetic without importing anything. This alone has saved countless financial applications from rounding bugs.

14. Interactive Editing

Python supports readline-based history and tab completion in its interactive interpreter.

Clojure developers typically use nREPL connected to their editor (Emacs + CIDER, VS Code + Calva, IntelliJ + Cursive). The experience goes far beyond line editing — you can evaluate any expression in your source file, inspect results inline, and navigate documentation without leaving your editor.

The Big Picture

Dimension Python Clojure
Paradigm Multi-paradigm (imperative + OOP + functional) Functional-first (with pragmatic escape hatches)
Mutability Mutable by default Immutable by default
Type System Dynamic, gradual typing via hints Dynamic, with optional specs
Concurrency GIL, async/await, multiprocessing STM, atoms, agents, core.async
Syntax Indentation-based, keyword-rich S-expressions, minimal syntax
OOP Classes, inheritance, dunder methods Protocols, multimethods, plain maps
Runtime CPython, PyPy JVM, JavaScript (ClojureScript)
Package Manager pip + venv deps.edn / Leiningen + Maven
REPL Culture Strong Even stronger
Ideal For Scripts, ML/AI, web, automation Data processing, concurrency, web, DSLs

Closing Thoughts

Python is a phenomenal language. Its readability, ecosystem, and community have earned it the top spot for good reason. But if you've ever felt the friction of debugging shared mutable state, wrestling with class hierarchies, or wishing your data transformations could be simpler — Clojure might be the language that makes you see programming differently.

You don't have to abandon Python. Many developers use both: Python for its unmatched ML/AI ecosystem and scripting ergonomics, Clojure for systems where correctness, concurrency, and data transformation matter most.

The best way to start is to fire up a REPL. Try Clojure's official getting started guide, or experiment in the browser at repl.it. The parentheses will feel strange for an hour. Then they'll feel like home.

This article was written as a companion to the official Python tutorial. Every section maps to a chapter in that tutorial, translated through the lens of Clojure's philosophy: simple, data-driven, and functional.

Permalink

LISP Prolog and Evolution

I just saw David Nolen give a talk at a LispNYC Meetup called:


LISP is Too Powerful

It was a provocative and humorous talk. David showed all the powerful features of LISP and said that the reason why LISP is not more used is that it is too powerful. Everybody laughed but it made me think. LISP was decades ahead of other languages, why did it not become a mainstream language?

David Nolen is a contributor to Clojure and ClojureScript.
He is the creator of Core Logic a port of miniKanren. Core Logic is a Prolog-like system for doing logic programming.

When I went to university my two favorite languages were LISP and Prolog. There was a big debate whether LISP or Prolog would win dominance. LISP and Prolog were miles ahead of everything else back then. To my surprise they were both surpassed by imperative and object oriented languages, like: Visual Basic, C, C++ and Java.

What happened? What went wrong for LISP?

Prolog

Prolog is a declarative or logic language created in 1972.

It works a little like SQL: You give it some facts and ask a question, and, without specifying how, prolog will find the results for you. It can express a lot of things that you cannot express in SQL.

A relational database that can run SQL is a complicated program, but Prolog is very simple and works using 2 simple principles:

  • Unification
  • Backtracking

The Japanese Fifth Generation Program was built in Prolog. That was a big deal and scared many people in the West in the 1980s.

LISP

LISP was created by John McCarthy in 1958, only one year after Fortran, the first computer language. It introduced so many brilliant ideas:

  • Garbage collection
  • Functional programming
  • Homoiconicity code is just a form of data
  • REPL
  • Minimal syntax, you program in abstract syntax trees

It took other languages decades to catch up, partly by borrowing ideas from LISP.

Causes for LISP Losing Ground

I discussed this with friends. Their views varied, but here are some of the explanations that came up:

  • Better marketing budget for other languages
  • Start of the AI winter
  • DARPA stopped funding LISP projects in the 1990s
  • LISP was too big and too complicated and Scheme was too small
  • Too many factions in the LISP world
  • LISP programmers are too elitist
  • LISP on early computers was too slow
  • An evolutionary accident
  • Lowest common denominator wins

LISP vs. Haskell

I felt it was a horrible loss that the great ideas of LISP and Prolog were lost. Recently I realized:

Haskell programs use many of the same functional programming techniques as LISP programs. If you ignore the parenthesis they are similar.

On top of the program Haskell has a very powerful type system. That is based on unification of types and backtracking, so Haskell's type system is basically Prolog.

You can argue that Haskell is the illegitimate child of LISP and Prolog.

Similarity between Haskell and LISP

Haskell and LISP both have minimal syntax compared to C++, C# and Java.
LISP is more minimal, you work directly in AST.
In Haskell you write small snippets of simple code that Haskell will combine.

A few Haskell and LISP differences

  • LISP is homoiconic, Haskell is not
  • LISP has a very advanced object system CLOS
  • Haskell uses monadic computations

Evolution and the Selfish Gene

In the book The Selfish Gene, evolutionary biologist Richard Dawkins makes an argument that genes are much more fundamental than humans. Humans have a short lifespan while genes live for 10,000s of years. Humans are vessels for powerful genes to propagate themselves, and combine with other powerful genes.

If you apply his ideas to computer science, languages, like humans, have a relatively short lifespan; ideas, on the other hand, live on and combine freely. LISP introduced more great ideas than any other language.

Open source software has sped up evolution in computer languages. Now languages can inherit from other languages at a much faster rate. A new language comes along and people start porting libraries.

John McCarthy's legacy is not LISP but: Garbage collection, functional programming, homoiconicity, REPL and programming in AST.

The Sudden Rise of Clojure

A few years back I had finally written LISP off as dead. Then out of nowhere Rich Hickey single-handedly wrote Clojure.

Features of Clojure

  • Run on the JVM
  • Run under JavaScript
  • Used in industry
  • Strong thriving community
  • Immutable data structures
  • Lock free concurrency

Clojure proves that it does not take a Google, Microsoft or Oracle to create a language. It just takes a good programmer with a good idea.

Typed LISP

I have done a lot of work in both strongly typed and dynamic languages.

Dynamic languages give you speed of development and are better suited for loosely structured data.
After working with Scala and Haskell I realized that you can have a less obtrusive type system. This gives stability for large applications.

There is no reason why you cannot combine strong types or optional types with LISP, in fact, there are already LISP dialects out there that do this. Let me briefly mention a few typed LISPs that I find interesting:

Typed Racket and Typed Clojure do not have as powerful types systems as Haskell. None of these languages have the momentum of Haskell, but Clojure showed us how fast a language can grow.

LISP can learn a lesson from all the languages that borrowed ideas from LISP.
It is nature's way.

Permalink

Using Generative AI tooling with Clojure

Clojure is easy to read for humans and AIs

Code written in Clojure is expressive and concise, but is still easy to reason about. In his “History of Clojure” paper Rich Hickey, the original author of Clojure states his motivations for building a new programming language:

“Most developers are primarily engaged in making systems that acquire, extract, transform, maintain, analyze, transmit and render information—facts about the world … As programs grew large, they required increasingly Herculean efforts to change while maintaining all of the presumptions around state and relationships, never mind dealing with race conditions as concurrency was increasingly in play. And we faced encroaching, and eventually crippling, coupling, and huge codebases, due directly to specificity (best-practice encapsulation, abstraction, and parameterization notwithstanding). C++ builds of over an hour were common.”

As a result, Clojure programs are very much focused on dealing with data and do it safely in concurrent programs. We have by default immutable data structures, easy to use literal representations of the most common collection types (lists, vectors, sets and maps) and a very regular syntax. A typical Clojure program has way less ceremony and boilerplate, not to mention weird quirks to deal with compared to many more programming languages such as Java, C#, Typescript or Python.

This means that large language models have less to deal with when reading or writing Clojure code. We have some evidence in Martin Alderson’s article that Clojure is token efficient compared to most other popular programming languages.

When we author code with generative AI tools, a developer reviewing it has less code to read in a format that easy to reason about.

Clojure MCP boosts Agentic development workflows

The REPL driven workflow speeds up the feedback cycle in normal development modes. Read Eval Print Loop is a concept in many programming languages in the LISP family such as Common Lisp, Scheme and Clojure. It allows the developer to tap into and evaluate code in a running instance of the application they are developing. With good editor integration, this allows smooth and frictionless testing of the code under development in an interactive workflow.

With the addition of MCP (Model Context Protocol) agents have gained access to a lot of tooling. In May 2025 Bruce Hauman announced his Clojure MCP that provides agents access to the REPL. Now AI agents such as Claude Code, Copilot CLI and others can reach inside the application as it is being developed, try code changes live, look at the internal state of the application and benefit from all of the interactivity that human developers have when working with the REPL.

It also provides efficient structural editing capabilities to the agents, making them less error-prone when editing Clojure source code. Because Clojure code is written as Clojure data structures, programmatic edits to the source code are a breeze.

We can even hot load dependencies to a running application without losing the application state! This is a SKILL.md file I have added to my project to guide agents in harnessing this power:

---
name: adding-clojure-dependencies
description: Adds clojure dependencies to the project. Use this when asked to add a dependency to the project
---

To add dependencies to deps.edn do the following:

1. Find the dependency in maven central repo or clojars
2. Identify the latest release version (no RCs unless specified)
3. Add dependency to either main list (for clojure dependencies),
   :test alias (for test dependencies) or :dev alias (for development dependencies). Use the REPL and
   `rewrite-edn` to edit the file
4. Reload dependencies in the REPL using `(clojure.repl.deps/sync-deps)`

In my experience, with the Clojure MCP coding agents have a far easier time troubleshooting and debugging compared to having them just analyze source code, logs and stacktraces.

As a developer, we can also connect to the same REPL as the coding agent, making it easy to step in and aid the agent when it gets stuck. In my workflows, I might look at the code the agent produced and test it in the REPL as well, make changes as required and instruct the agent to read what I did. This gives another collaborative dimension to standard prompting techniques that are normally associated with generative AI development.

Getting AI to speak Clojure

Generating and analyzing code with AI tooling is just one way to apply AI in software development. As developers, we should understand the potential for embedding AI functionality at the application level too. LLMs seem to be good at understanding my intentions, even if they don’t necessarily produce the right results. One possibility is to take input provided by a human and enrich it with data so that further processing becomes easier. For the sake of experiment, let’s look at a traditional flow of making a support request.

The user goes to the portal and their first task is to identify the correct topic under which this support request belongs to. They usually have to classify the severity of the issue as well. Then they describe what problem they have and add their contact information. With this flow, there’s a large chance that the user misclassified their support request, causing delays in getting the work in front of the right person, making the user experience poor and causing frustrations in the people handling the support requests. From the user’s point of view they don’t care about which department picks up the request, so this system is pushing the support organization’s concerns to the end user.

What if we could avoid all that and have the request routed automatically to the right backlog? Enter OpenAI’s Requests API which can handle text, image and various file inputs to generate text or JSON outputs. The json_schema response format is interesting in particular because we can express the desired result format in a manner that we can then use to process the response programmatically down the line.

In the Clojure world, we often use Malli to define our data models. We can use malli.json-schema to transform our malli schemas into JSON schema that the endpoint understands, and then use malli.transform to translate the response from JSON back to Clojure data.

A similar idea has been shown previously by Tommi Reiman, author of Malli.

Note that the choice of model can have a big effect on your output!

(require '[malli.json-schema :as mjs])
(require '[malli.core :as m])
(require '[malli.transform :as mt])
(require '[cheshire.core :as json])
(require '[org.httpkit.client :as http])

(defn structured-output
  [api-endpoint-url api-key malli-schema input]
  (let [;; Convert Malli schema to JSON Schema
        json-schema (mjs/transform malli-schema)

        ;; Build request body
        body {:model "gpt-4o" ;; consult your service provider for available models
              :input input
              :text {:format {:type "json_schema"
                              :name "response"
                              :strict true
                              :schema json-schema}}}

        ;; Make HTTP request
        response @(http/post
                   (str api-endpoint-url "/v1/responses")
                   {:headers {"Authorization" (str "Bearer " api-key)
                              "Content-Type" "application/json"}
                    :body (json/generate-string body)})

        ;; Parse response
        parsed-response (json/parse-string (:body response) true)

        ;; Extract structured data from response
        content (-> parsed-response :output first :content first :text)
        parsed-data (json/parse-string content true)

        ;; Decode using Malli transformer
        result (m/decode malli-schema parsed-data (mt/json-transformer))]

    ;; Validate and return
    (when-not (m/validate malli-schema result)
      (throw (ex-info "Response does not match schema"
                      {:schema malli-schema
                       :result result})))
    result))

(structured-output
   (get-base-url)
   (get-api-key)
   [:map
    [:department [:enum :licences :hardware :accounts :maintenance :other]]
    [:severity [:enum :low :medium :critical]]
    [:request :string]
    [:contact-information [:map
                           [:email {:optional true} :string]
                           [:phone {:optional true} :string]
                           [:raw :string]]]]
   "Hi, my laptop diesn't let me login anymore. Can't work. What do? t. Hank PS call me 555-1343 because I can't access email")
; => {:department :hardware,
;     :severity :critical,
;     :request "Laptop doesn't let me login anymore. Can't work.",
;     :contact-information
;     {:raw "Hank, phone: 555-1343, cannot access email",
;      :phone "555-1343"}}

Notice how the model was able to process the human input that contained typos and informal, broken language. One doesn’t usually have to use any of the more powerful and expensive models to do this level of work. The older json_object response format has some capabilities and supports even lighter models. See OpenAI’s documentation for reference.

I think methods like this make it easy to embed LLM-enabled functionality in Clojure applications, giving them capabilities that are normally very hard to implement using traditional methods.

DISCLAIMER

If you were to implement the above example in your application as is, you’d be sending personal data to OpenAI. As developers, we should always consider the larger implications. You can deploy models locally in your region and keep data residency and processing within the EU region for example by using Solita’s FunctionAI in UpCloud or other service providers.

Further reading

If you’re new to agentic development, Prompt engineering 101 is a great starter for how to get past the first hurdles.

Permalink

Copyright © 2009, Planet Clojure. No rights reserved.
Planet Clojure is maintained by Baishamapayan Ghose.
Clojure and the Clojure logo are Copyright © 2008-2009, Rich Hickey.
Theme by Brajeshwar.