LangGraph isn't cheaper than LangChain — unless you opt out of its defaults

LangGraph isn't cheaper than LangChain — unless you opt out of its defaults

Cost-audit series, episode 4. This series began with an AI agent that burned 136M tokens overnight →.

When LangChain deprecated ConversationBufferMemory (the subject of episode 1 in this series), the official migration path was LangGraph. The pitch: explicit state management, you control exactly what flows where. More expressive, more controllable.

It is — but only if you reach for the controls. The default state model in LangGraph has the same unbounded-growth problem as the memory it replaced. Teams migrating to escape ConversationBufferMemory's cost curve often land on an identical curve, with new graph complexity on top.

This audit shows exactly where the default grows, what it costs, and what opt-outs exist.

The default: MessagesState + add_messages

The quickstart in LangGraph's own docs uses this pattern:

from langgraph.graph import StateGraph, MessagesState

def my_node(state: MessagesState):
    messages = state["messages"]
    response = llm.invoke(messages)   # sends ALL messages to the LLM
    return {"messages": [response]}

graph = StateGraph(MessagesState)
graph.add_node("agent", my_node)

MessagesState is a TypedDict with a single key, messages, backed by the add_messages reducer. Here's what that reducer does:

# langgraph/graph/message.py — add_messages (def at line 18; merge loop below)
def add_messages(left: Messages, right: Messages) -> Messages:
    # ... (coerces left/right to lists of BaseMessage) ...
    left_idx_by_id = {m.id: i for i, m in enumerate(left)}
    merged = left.copy()
    ids_to_remove = set()
    for m in right:
        if (existing_idx := left_idx_by_id.get(m.id)) is not None:
            if isinstance(m, RemoveMessage):
                ids_to_remove.add(m.id)
            else:
                merged[existing_idx] = m      # same id → update in place
        else:
            merged.append(m)                  # new id → APPEND (the list grows)
    merged = [m for m in merged if m.id not in ids_to_remove]
    return merged

Source: langgraph/graph/message.py

This is not a summarizer, not a window, not a trimmer. It is an append-only list. Every message ever added to state stays in state — and every node that reads state["messages"] sees the full list.

This is ConversationBufferMemory with a graph wrapper.

The cost math

Assume a conversational agent: 10 turns, 150 tokens per user message, 200 tokens per assistant reply (modest — a short answer each time).

After 10 turns, state["messages"] contains 20 messages = (10 × 150) + (10 × 200) = 3,500 tokens of accumulated history.

For the 11th call, the node sends all 3,500 tokens of prior history as context, then generates a new reply. Each further turn adds another 350 tokens (150 user + 200 assistant), so the 12th call sends 3,850, the 13th 4,200, and so on.

Total input tokens for a 20-turn conversation:

Turn Messages in state Input tokens (messages + system)
1 0 prior 150 + 400 (system)
5 4 prior turns 1,550 + 400
10 9 prior turns 3,300 + 400
15 14 prior turns 5,050 + 400
20 19 prior turns 6,800 + 400
Total ~77,500 tokens input

(Each call = 400 system + 150 current user + (turn−1) × 350 accumulated history.)

A naive estimate (flat 550 tokens/call × 20 calls) = 11,000 tokens.

Actual with add_messages default = ~77,500 tokens. 7× over.

With claude-haiku-4-5 ($0.80/M input, $4/M output) for a chatbot doing 500 conversations/day:

  • Naive estimate: 11,000 × 500 × 30 × $0.80/M = $132/month
  • Actual: 77,500 × 500 × 30 × $0.80/M = $930/month

That's $798/month of silent overspend on input tokens alone, just from the default accumulation — before you add nodes, tools, or memory.

Multiplier 1: multi-node graphs (each node pays the full state)

LangGraph's value over a simple chat loop is composing multiple nodes — a router, a tool-caller, a summarizer, a responder. Each node that reads state["messages"] pays the full token cost of the accumulated message list.

graph = StateGraph(MessagesState)
graph.add_node("router", route_node)      # reads state["messages"]
graph.add_node("tool_caller", tool_node)  # reads state["messages"]
graph.add_node("responder", respond_node) # reads state["messages"]

For a 3-node graph where each node reads messages, a single user turn that passes through all three nodes costs the message-list tokens. After 10 turns with 3,500 accumulated tokens, one user message costs: 3 × 3,500 = 10,500 tokens just for message history, before any node-specific prompts.

Multiplier 2: interrupt_before / interrupt_after (human-in-the-loop)

LangGraph's human-in-the-loop feature pauses graph execution at a node boundary. When the graph resumes, it deserializes the full checkpointed state and re-injects it into the next node:

# langgraph/pregel/__init__.py — Pregel.astream (v0.2.60), the entrypoint
# that drives interruptible execution. Verbatim signature:
async def astream(
    self,
    input: Union[dict[str, Any], Any],
    config: Optional[RunnableConfig] = None,
    *,
    stream_mode: Optional[Union[StreamMode, list[StreamMode]]] = None,
    output_keys: Optional[Union[str, Sequence[str]]] = None,
    interrupt_before: Optional[Union[All, Sequence[str]]] = None,
    interrupt_after: Optional[Union[All, Sequence[str]]] = None,
    debug: Optional[bool] = None,
    subgraphs: bool = False,
) -> AsyncIterator[Union[dict[str, Any], Any]]:
    ...

Source: langgraph/pregel/__init__.py — search the file for async def astream (defined ~line 1683; interrupt_before/interrupt_after are the pause controls). When a run resumes after an interrupt, Pregel reloads the pending state from the checkpointer (the aget_tuple/aget_state path returns the full checkpoint blob — every message included) before continuing at the next node.

The cost: full state deserialization on every resume. If a workflow interrupts 3 times before completion (a common approval flow), and the state has 5,000 tokens of messages, the resumption overhead alone is 3 × 5,000 = 15,000 extra tokens — paid every time, even if the approval is just a "yes."

Multiplier 3: parallel fan-out (Send API)

LangGraph's Send API dispatches parallel subgraph invocations, each receiving a copy of state:

from langgraph.types import Send

def fanout_node(state: MessagesState):
    return [
        Send("worker_a", {"messages": state["messages"], "task": "summarize"}),
        Send("worker_b", {"messages": state["messages"], "task": "critique"}),
        Send("worker_c", {"messages": state["messages"], "task": "expand"}),
    ]

Source: langgraph/types.py

Each Send carries the full state["messages"] to the worker node. With 3 workers and 5,000 tokens of history: 15,000 tokens dispatched in the fan-out alone. If those workers themselves call an LLM, each call pays the full 5,000-token history again. Compare to the CrewAI quadratic problem from episode 3 — this is the same failure mode, different API.

The opt-outs (LangGraph actually provides them)

Unlike ConversationBufferMemory (which had no good trim story), LangGraph ships built-in tools to fix this. Teams just don't use them by default.

Trim messages before every LLM call

from langchain_core.messages import trim_messages

def my_node(state: MessagesState):
    trimmed = trim_messages(
        state["messages"],
        max_tokens=2000,          # hard cap
        strategy="last",          # keep the most recent
        token_counter=llm,        # use the model's tokenizer
        include_system=True,      # always keep the system message
        allow_partial=False,
    )
    response = llm.invoke(trimmed)   # sends trimmed history, not full list
    return {"messages": [response]}

Source: langchain_core/messages/utils.py

This keeps the full history in state (for checkpointing, human inspection) while capping what the LLM actually sees. Applying a 2,000-token cap on a 20-turn conversation reduces input tokens from ~77,500 to ~40,000 (2,000 tokens × 20 calls). ~48% cost reduction, one line change.

Pass only what the node needs

Instead of giving every node the full state["messages"], scope what each node receives:

class MyState(TypedDict):
    messages: Annotated[list[BaseMessage], add_messages]
    last_tool_result: str        # structured, compact
    task_description: str        # set once, doesn't grow

def tool_caller_node(state: MyState):
    # This node only needs the task + last result — not the full conversation
    prompt = f"Task: {state['task_description']}\nContext: {state['last_tool_result']}"
    response = llm.invoke(prompt)
    return {"last_tool_result": response.content}

The tool_caller_node never touches state["messages"] — it pays zero for message accumulation. Only the nodes that genuinely need conversational context receive it.

Summarize periodically (the right way)

def maybe_summarize(state: MessagesState):
    messages = state["messages"]
    if len(messages) > 20:                # threshold: tune to your cost tolerance
        summary = llm.invoke([
            SystemMessage("Summarize this conversation in 3 sentences."),
            *messages,
        ])
        return {
            "messages": [
                SystemMessage(f"Conversation summary: {summary.content}"),
                messages[-2],    # keep the last human message
                messages[-1],    # keep the last assistant message
            ]
        }
    return {}   # no change needed

This collapses the accumulated history into a single system message at the summarization trigger. After summarization, the effective context is ~200 tokens (summary + last exchange) instead of 3,500+. Insert maybe_summarize as an always-on node before expensive LLM calls.

Measuring your own graph

LangGraph's built-in tracing (via LangSmith) shows per-node token usage, but it's behind a paid tier for production volumes. For a free alternative that works with any JSONL export:

npm install -g @wartzar-bee/tokenscope
npx @wartzar-bee/tokenscope langgraph-session.jsonl

Output shows the session total, how much of each call is re-sent accumulated context versus new work, and where the growth is steepest — the same view that surfaced the 136M-token burn in episode 1.

Summary

LangGraph's MessagesState + add_messages default is ConversationBufferMemory under a new name. The graph model gives you explicit controls that the old memory API lacked — but the controls are opt-in. Without trim_messages, selective state reads, or periodic summarization, migration from LangChain to LangGraph buys you graph expressiveness at the same (or higher, for multi-node graphs) token cost.

Pattern Cost impact Fix
MessagesState default 7× over naive estimate at 20 turns trim_messages before every LLM call
Multi-node graph, all nodes read messages N× multiplier (N = node count) Scope state: only pass what each node needs
interrupt/resume Full state re-injected on every resume Summarize before checkpointing at long sessions
Send fan-out Parallel full-state copies Pass minimal substate to each worker

The migration from LangChain to LangGraph is worth it — but only once you understand and explicitly opt out of these defaults. Otherwise you're paying for the complexity without the savings.

wartzar-bee builds tools for operating cost-efficient autonomous agents. tokenscope is free and open-source. Follow on dev.to →

Permalink

Sharpening CIDER’s Debugging Tools

The series on the notable changes in CIDER 2.0 rolls on. This time: the “what is my code actually doing?” toolbox - the debugger, tracing, enlighten, and the new tap viewer. This was the part of the release I enjoyed working on the most, and the part that needed the most love.

The debugger, dusted off

CIDER’s interactive debugger is one of its most impressive features and, paradoxically, one of its least reliable ones. Instrumenting arbitrary Clojure code is hard - the debugger rewrites your forms to capture locals at every step, and the corner cases are endless. Over the 2.0 cycle (and the 0.62.x releases of cider-nrepl) a whole family of long-standing instrumentation bugs got fixed:

  • Record literals embedded in code survive instrumentation instead of being quietly downgraded to plain maps - which used to break protocol dispatch in anything that compiled routes or components into records (compojure users know the pain).
  • defrecord/deftype inline methods no longer blow up with the infamous Unable to resolve symbol: STATE__ error - the instrumenter now sensibly skips the method bodies, which compile to real JVM methods that can’t capture debugger state. #dbg on a bare collection literal triggered the same error; fixed too. And heavily destructured argument lists used to crash instrumentation in their own special way - not anymore.
  • A form too large to instrument (yes, that’s a JVM limitation - Method code too large!) now degrades gracefully: CIDER retries without local capture and tells you what happened, instead of surfacing a raw compiler error.
  • Quitting a debug session uses nREPL’s interrupt machinery instead of the deprecated Thread.stop, so it keeps working on modern JDKs where Thread.stop is simply gone.

The UX got attention too. Quitting the debugger with q finally restores point to where you started the session - a request filed in 2016 - instead of stranding you at the last breakpoint. The force-step-out key works again. And all the debugger’s single-key commands are now proper named commands with a transient menu (?) listing them, so you’re never stuck trying to remember whether locals was l or L.

Tracing grew a home

clojure.tools.trace-style tracing has been in CIDER forever, but the output was always interleaved into the REPL, where it fought with your actual work. CIDER 2.0 gives traces a dedicated, live-streaming *cider-trace* buffer:

The *cider-trace* buffer showing a nested call tree with return values

Calls fold and unfold (TAB, or F/U for everything at once), n/p move between calls, and . jumps to a function’s definition. cider-list-traced answers the eternal “wait, what did I even trace?”, and cider-untrace-all cleans the slate.

Enlighten, back from the dead

Enlighten - the mode that displays the values of locals inline as your code runs - has been in “experimental” limbo since 2016. It finally got a proper overhaul: a real test suite, fixes for the same record/deftype instrumentation bugs as the debugger (they share machinery), and - importantly - manners. You can now enlighten a single form with cider-enlighten-defun-at-point instead of flipping a global mode, and cider-enlighten-stop turns everything off at once, rather than making you re-evaluate every function in penance.

Every local and every intermediate result, right there in the buffer:

Enlighten showing argument and return values inline in the source

Tap into your programs

New in 2.0: cider-tap, a buffer that streams every value sent to tap> and lets you crack any of them open in the inspector with RET. tap> has quietly become the Clojure community’s favorite debugging primitive, and now you don’t need an external tool like Portal or Reveal for the basic workflow - though those remain great if you want more. (ClojureScript taps stream too; they’re just not inspectable, since the values live in the JS runtime.)

It’s println debugging, minus the println guilt.

The connective tissue

A few related quality-of-life items round out the picture: stack frames for top-level anonymous functions jump to their actual source instead of clojure.core/fn (a bug from 2020), ClojureScript frames render their ns/fn properly, and the macroexpansion tooling - a debugging tool in its own right - got a full makeover that deserves (and will get) its own article.

None of these tools is new. That’s rather the point: the 2.0 debugging story is mostly the existing tools becoming trustworthy. A debugger you don’t trust is worse than no debugger at all.

The debugging docs cover everything in detail. Keep hacking!

Articles in the Series

Permalink

Clojure 1.13.0-alpha6

Clojure 1.13.0-alpha6 is now available! Find download and usage information on the Downloads page.

Destructuring additions and changes

  • To get a map of all keys in the input, augmented by the defaults, traversing deeply through nested maps, use the :all directive.

  • Report as errors any use of unbound/unreferenced keys in :or when using any of the new constructs (:defaults, :select et al)

  • CLJ-2972 Destructuring test clean up for :or entries

Try it out

Update your deps.edn :deps with:

org.clojure/clojure {:mvn/version "1.13.0-alpha6"}

Start a REPL with the Clojure CLI (any version) with:

clj -Sdeps '{:deps {org.clojure/clojure {:mvn/version "1.13.0-alpha6"}}}

Permalink

Babashka tasks with automatic help and completions

Babashka tasks is a project task manager that is part of babashka since May 2021. It&aposs a practical way to manage a Clojure or other software project. Just invoking bb tasks gives you an overview of everything that is important to developers of this project. E.g. launching a REPL, bumping and releasing new versions, etc.

Example bb.edn:

{:tasks
 {dev   {:doc "Start the dev system"
         :task (clojure "-M:dev")}

  test  {:doc "Run the tests"
         :task (clojure "-M:test")}

  build {:doc "Build an uberjar"
         :depends [test]
         :task (clojure "-T:build uber")}}}
$ bb tasks
The following tasks are available:

dev    Start the dev system
test   Run the tests
build  Build an uberjar

The bb build task runs test first because it :depends on it.

Before: exec and -x

Babashka CLI is a command line parsing library that comes bundled with babashka. Babashka tasks already had some integration with it before, through the exec function. The exec function auto-resolve a var symbol and that var can carry a :org.babashka/cli specification.

bb.edn:

{:paths ["bb"]
 :tasks {dev {:doc "Start the dev system"
              :task (exec &apostasks/dev)}}}

bb/tasks.clj:

(ns tasks
  (:require [babashka.tasks :refer [clojure]]))

(defn dev
  {:org.babashka/cli {:spec {:port {:coerce :int
                                    :default 8080}}}}
  [opts]
  (clojure "-X:dev" opts))
$ bb dev
dev system on port 8080

$ bb dev --port 3000
dev system on port 3000

Babashka&aposs -x flag already invoked that function by its qualified name:

$ bb -x tasks/dev
dev system on port 8080

$ bb -x tasks/dev --port 3000
dev system on port 3000

Since a couple of months, Babashka CLI provides automatic help and completions. Babashka tasks now uses those when configured with either :exec-fn or :cmd.

Opting in with :exec-fn

To opt-in to that new behavior, the dev task becomes:

bb.edn:

{:paths ["bb"]
 :tasks {dev {:exec-fn tasks/dev}}}

bb/tasks.clj:

(ns tasks
  (:require [babashka.tasks :refer [clojure]]))

(defn dev
  "Start the dev system"
  {:org.babashka/cli {:spec {:port {:coerce :int
                                    :default 8080
                                    :desc "HTTP port"}}}}
  [opts]
  (clojure "-X:dev" opts))
$ bb dev --help
Usage: bb dev [options]

Start the dev system

Options:
      --port  HTTP port (default: 8080)
  -h, --help  Show this help

The function&aposs docstring also becomes the task description in bb tasks:

$ bb tasks
The following tasks are available:

dev  Start the dev system

Task explosion

One thing I noticed in some projects is that during their lifecycle, tasks tended to get copied when they need another option.

$ bb tasks
The following tasks are available:

dev                              Start the dev system
dev:with-transactor              Start the dev system with a transactor
dev:with-transactor:with-nrepl   Start the dev system with a transactor and nREPL

I now think that this is a "task smell" and this should be only one task with command line options:

bb/tasks.clj:

(defn dev
  "Start the dev system"
  {:org.babashka/cli {:spec {:port       {:coerce :int
                                          :default 8080
                                          :desc "HTTP port"}
                             :transactor {:coerce :boolean
                                          :desc "Start a transactor"}
                             :nrepl      {:coerce :boolean
                                          :desc "Start an nREPL server"}}}}
  [opts]
  (clojure "-X:dev" opts))
$ bb dev --help
Usage: bb dev [options]

Start the dev system

Options:
      --port        HTTP port (default: 8080)
      --transactor  Start a transactor
      --nrepl       Start an nREPL server
  -h, --help        Show this help

$ bb dev --transactor
dev system on port 8080 transactor=true nrepl=false

Shell completions

To enable completions, you can use bb org.babashka.cli/completions snippet --shell <shell> for your specific shell.

E.g. for zsh, we do this by adding this to ~/.zshrc after compinit:

source <(bb org.babashka.cli/completions snippet --shell zsh)

For Bash, fish, PowerShell, and Nushell, see Completions in the Babashka CLI README.

After doing that, task name completion includes descriptions on auto-complete:

$ bb <TAB>
dev  -- Start the dev system

Task specific option completions includes descriptions:

$ bb dev <TAB>
--help  -h  -- Show this help
--port      -- HTTP port

You can call the completions command directly to inspect its output without a shell, e.g. for debugging:

$ bb org.babashka.cli/completions complete --shell zsh -- dev &apos&apos
--port	HTTP port
--help	Show this help
-h	Show this help

Restricting values with :enum

Use :enum to restrict an option to a fixed set of values:

(defn dev
  "Start the dev system"
  {:org.babashka/cli {:spec {:port {:coerce :int
                                    :default 8080
                                    :desc "HTTP port"}
                             :env  {:desc "Environment"
                                    :enum ["dev" "staging" "prod"]
                                    :default "dev"}}}}
  [opts]
  (clojure "-X:dev" opts))

The help output lists the allowed values:

$ bb dev --help
Usage: bb dev [options]

Start the dev system

Options:
      --port  HTTP port (default: 8080)
      --env   Environment (one of: dev, staging, prod) (default: dev)
  -h, --help  Show this help

The value is also validated:

$ bb dev --env qa
Error: Invalid value for option --env: qa. Expected one of: dev, staging, prod

Usage: bb dev [options]

Run "bb dev --help" for more information.

Shell completion also uses the :enum values:

$ bb dev --env <TAB>
dev  prod  staging

Commands

Add commands to a task with :cmd:

bb.edn:

{:paths ["bb"]
 :tasks {dev {:exec-fn tasks/dev}

         db  {:doc "Manage the database"
              :cmd {"migrate" {:exec-fn tasks/db-migrate}
                    "seed"    {:exec-fn tasks/db-seed}}}}}

Each leaf uses :exec-fn to point to a function:

bb/tasks.clj:

(defn db-migrate
  "Run pending migrations"
  {:org.babashka/cli {:spec {:env {:desc "Environment"
                                   :enum envs
                                   :default "dev"}}}}
  [{:keys [env]}]
  (println "migrating" env))

(defn db-seed
  "Seed the database with fixtures"
  {:org.babashka/cli {:spec {:env {:desc "Environment"
                                   :enum envs
                                   :default "dev"}}}}
  [{:keys [env]}]
  (println "seeding" env))

A top-level :exec-fn can handle bb db. When no command is specified:

$ bb db
No command given.

Automatic help lists the commands and provides separate help for each:

$ bb db --help
Usage: bb db [options] <command>

Manage the database

Commands:
  migrate  Run pending migrations
  seed     Seed the database with fixtures

Options:
  -h, --help  Show this help

Run "bb db <command> --help" for more information on a command.

Shell completion includes commands and option values:

$ bb db <TAB>
migrate  -- Run pending migrations
seed     -- Seed the database with fixtures

$ bb db migrate --env <TAB>
dev  prod  staging

CLI settings with :cli

Use :cli for Babashka CLI settings, such as a help epilog:

bb.edn:

{:paths ["bb"]
 :tasks {dev {:exec-fn tasks/dev}

         db  {:doc "Manage the database"
              :cli {:epilog "Migration code is in resources/migrations."}
              :cmd {"migrate" {:exec-fn tasks/db-migrate}
                    "seed"    {:exec-fn tasks/db-seed}}}}}
$ bb db --help
Usage: bb db [options] <command>

Manage the database

Commands:
  migrate  Run pending migrations
  seed     Seed the database with fixtures

Options:
  -h, --help  Show this help

Run "bb db <command> --help" for more information on a command.

Migration code is in resources/migrations.

For tasks that require code outside of bb.edn, :cli may contain a fully qualified var symbol:

bb.edn:

{:paths ["bb"]
 :tasks {dev {:exec-fn tasks/dev}

         db  {:doc "Manage the database"
              :cli tasks/db-cli
              :cmd {"migrate" {:exec-fn tasks/db-migrate}
                    "seed"    {:exec-fn tasks/db-seed}}}}}

bb/tasks.clj:

(defn- report-error
  [{:keys [msg]}]
  (binding [*out* *err*]
    (println "db:" msg))
  (System/exit 1))

(def db-cli
  {:epilog "Migration code is in resources/migrations."
   :error-fn report-error})
$ bb db migrate --env qa
db: Invalid value for option --env: qa. Expected one of: dev, staging, prod

Top-level :cli options in :tasks apply to every CLI task:

{:paths ["bb"]
 :tasks {:cli tasks/db-cli
         ...}}

Multi-line docs

A :doc value may be a vector of lines:

bb.edn:

db {:doc ["Manage the database"
          "Migrations are applied in order and are idempotent."]
    :cmd {"migrate" {:exec-fn tasks/db-migrate}
          "seed"    {:exec-fn tasks/db-seed}}}
$ bb db --help
Usage: bb db [options] <command>

Manage the database
Migrations are applied in order and are idempotent.

Commands:
  migrate  Run pending migrations
  seed     Seed the database with fixtures

Options:
  -h, --help  Show this help

Run "bb db <command> --help" for more information on a command.

The bb tasks overview prints only the first line:

$ bb tasks
The following tasks are available:

dev  Start the dev system
db   Manage the database

Availability

The task integration is available in babashka 1.13.219. The Babashka CLI features it builds on are in 0.12.85:

org.babashka/cli {:mvn/version "0.12.85"}

Closing remarks

I hope you&aposll enjoy these new additions to bb tasks! The new task keys should be considered experimental and may change in a future version of babashka, depending on feedback from the community.

Permalink

Learning new things is hard

I&aposve been hearing more and more things about jujutsu and want to try it out. It is so hard to learn new things that replace something I&aposve been using almost every day for over 10 years though. The muscle memory is so ingrained and my mental model of what version control even is is so rigid, it&aposs hard to wrap my head around the idea that it could be anything other than git&aposs. My main motivation is that I&aposve "heard" it&aposs better for a) working with stacked branches and b) working on multiple worktrees (called workspaces) at once. These are both things I do way more now because of all the agent-driven-development stuff I&aposm learning than I ever did before and I feel like I am spending a ridiculous amount of time fighting git. But learning new things is so hard. Wish me luck.

Permalink

Closing the Find-Usages Gap in CIDER

Next up in the series on the notable changes in CIDER 2.0: cross-references. Or, as most people call the feature, “find usages” - for years the most commonly cited reason to run clojure-lsp alongside (or instead of) CIDER. Let’s talk about why that gap existed and how we finally closed it.

Why runtime xref wasn’t enough

CIDER has had runtime cross-referencing for a while: the cider/fn-refs op walks the loaded vars in your REPL and reports which functions reference the one at point. It’s a genuinely cool trick - the REPL literally knows your program - but as a “find usages” answer it has three structural problems:

  • It only sees loaded code. Namespaces you haven’t required yet - often most of the codebase - are invisible.
  • It reports functions, not occurrences. Each hit points at the caller’s definition, not the exact call site, and a function that calls yours three times shows up once.
  • It’s JVM-only, so ClojureScript users got nothing.

clojure-lsp, by contrast, builds a static index of your whole project with clj-kondo’s analyzer and answers instantly, loaded or not. For occurrence-oriented questions (“show me every place this is used, so I can change all of them”), static analysis is simply the right tool. No amount of runtime cleverness fixes “the code isn’t loaded”.

The fix: search the source

So CIDER 2.0 does the obvious thing we should have done years ago: xref-find-references (M-?) now finds references by searching the project’s source files on disk. Unloaded code, cljs files, commented-out drafts - if the name occurs in the project, you’ll see the exact occurrence, in the standard xref UI you already use for everything else in Emacs. To borrow the franchise that has been handing programmers debugging metaphors for over two decades now: the runtime is the Matrix, a tidy compiled illusion of your program, and to see every place a thing is really used you sometimes have to unplug and look at the source itself.1

Here’s the difference in one picture - the same query on orchard.misc/require-and-resolve, first in runtime mode, then in source mode:

Find-usages of orchard.misc/require-and-resolve: the runtime search returns only the loaded callers, one per calling function, while the source search finds every occurrence across the project - including the alias-qualified uses in other namespaces and a cljc file the REPL never loaded

The runtime knows about three callers - and points you at each caller’s definition. The source scan turns up all ten actual occurrences, across five files, including the ones written as misc/require-and-resolve in namespaces the REPL never loaded. That’s the gap, in one screenshot.

Now, “search the source” makes it sound like a grep, and I want to be clear that it isn’t a dumb one. Say you’re chasing orchard.misc/require-and-resolve. The search runs in three stages:

  1. First CIDER asks the REPL to resolve the symbol at point to its fully qualified name, orchard.misc/require-and-resolve. The REPL is right there on the other end of the wire, so why guess when you can ask?
  2. A fast first pass (ripgrep, via Emacs’ own xref-matches-in-files) finds every file that so much as mentions require-and-resolve, purely to narrow the field.
  3. Then the Clojure-aware part. For each of those files, CIDER reads its (ns ...) form to learn how that file pulls in orchard.misc - aliased as [orchard.misc :as misc], brought in with :refer [require-and-resolve], or is this orchard.misc itself? - and builds a regexp that matches only the forms that file could legitimately use: the qualified orchard.misc/require-and-resolve, the aliased misc/require-and-resolve, or a bare require-and-resolve where the namespace declaration makes that valid. The requires inside the ns form are excluded, so the import line doesn’t show up as a “usage”.2

Is any of this as smart as clj-kondo’s full analysis? No - it’s ultimately a syntactic search, so an identically named var in another namespace can still sneak through as a false positive. But because it’s ns-aware rather than a blind text match, a bare require-and-resolve in some file that never requires orchard.misc (and has a require-and-resolve of its own) won’t be mistaken for yours. For the daily “where is this used?” question it turns out to be remarkably close to the real thing in practice, it requires zero extra infrastructure, and it composes with what only CIDER has: the running REPL.

That composition is configurable via cider-xref-references-mode:

  • source (the default) - occurrences from the project’s files.
  • runtime - the historical loaded-vars behavior.
  • both - source occurrences first, plus the runtime hits the scan can’t see. And there are such hits: references generated by macro expansion leave no textual trace in your source, but the runtime knows about them. Static analysis can’t ever tell you those; your REPL can.

(There’s also cider-xref-fn-refs-in-source, C-c C-? s, when you want the source search explicitly, and outside a project the source mode gracefully falls back to the runtime search.)

Beyond find usages: the who-* family

While closing the gap, we went further and built out a whole family of SLIME-inspired cross-referencing commands under C-c C-w, most of them rendered as expandable trees:

  • cider-who-calls / cider-who-is-called - the call graph, upward and downward. Expand a caller to see its callers; spelunk as deep as you like:

    The cider-who-calls tree, expanded two levels up the call graph

  • cider-who-implements - a protocol’s implementing types (inline defrecord/deftype implementations included) or a multimethod’s dispatch values, each jumping to the implementation’s source. Multimethods are a nice case study in hybrid thinking: the method functions carry no source metadata at runtime, so CIDER locates the defmethod forms by - you guessed it - searching the source.
  • cider-type-protocols / cider-protocols-with-method - the reverse lookups: what does this type implement, and which protocols declare this method?
  • cider-who-macroexpands - a macro’s use sites, found via source search, because macro invocations are expanded away at compile time and the runtime literally cannot see them.

Much of this is powered by new ops in cider-nrepl (and Orchard underneath), and much of the inspiration came straight from SLIME and swank-clojure, which offered who-calls back when Clojure itself was barely out of the crib. And here’s the part I love: the whole “go read the source instead of trusting the runtime” instinct was already there in swank-clojure’s implementation. Its who-calls would find candidate callers among the loaded vars, sure, but then it went and read the actual source form of each one off disk and walked the parsed code looking for your symbol, rather than believing whatever the compiled runtime claimed. It was still anchored to loaded code - it never scanned the whole project the way CIDER 2.0 does - but the core idea, that the source on disk is the ground truth and the runtime is just a convenient approximation, predates this release by about fifteen years. Good ideas don’t expire. Sometimes nothing beats revisiting the classics.3

So do you still need clojure-lsp?

If you were running clojure-lsp primarily for find-usages - the most common answer I heard when I asked - then CIDER now covers you out of the box. If you use it for project-wide renames, unused-var linting, or editing without a REPL, carry on; those are real strengths of static analysis and CIDER doesn’t try to replicate them. The two continue to work fine side by side, and the new async eldoc even yields politely so LSP-provided docs can compose with CIDER’s.

My goal was never to “beat” clojure-lsp - it was to make a freshly installed CIDER answer the questions every Clojure programmer asks a dozen times a day, with no extra moving parts, and with the one advantage nobody else has: a live runtime on the other end of the wire.

The full story is in the navigation docs. Keep hacking!

Articles in the Series

  1. As Morpheus puts it: “Unfortunately, no one can be told what the Matrix is. You have to see it for yourself.” Same with find-usages, really - I can tell you a var is used in seven places, but until you’ve seen the actual call sites you don’t really know what changing it will break. 

  2. Full disclosure: matching the aliased and namespace-qualified forms correctly only landed after 2.0 - the 2.0.x releases had a bug where the source scan quietly skipped files that referenced a var through its alias, which is of course the common case. The fix will ship in CIDER 2.1, which doesn’t have a release date yet. I’m hoping to get back into a rhythm of cutting a new CIDER release every month or two, so it shouldn’t be a long wait. 

  3. “The path of the One ends at the Source.” The Architect was talking about Zion, but he might as well have been describing every debugging session that ends with you finally opening the file and reading the code instead of theorizing about it. 

Permalink

Clojure vars - notes

Intro

  • named references (symbol) to values of fn
  • they live in a namespace
  • mutable reference to an immutable value

Var is separate from the value so you can redefine it

Note: symbol is just name

Dig Deeper

Functions are actually stored in vars

(defn greet [name] (str "hello" name))
; is equal to 
(def greet (fn [name] (str "hello" name)))
; because defn is macro

Dynamic vars

Allows temporary thread-local bindings

(def ^:dynamic *debug* false)

; temporarily change it
(binding [*debug* true]
 (prinlnt *debug*))

Metadata

(def ^{:doc "A counter"} counter 0)

Symbol vs Var

Symbol

Pice of code that names something

(type 'x)
;; => clojure.lang.Symbol

Writen as 'x
Exists in code and data

Var

Runtime object that point to immutable value
Written as #'x or (var x)
Exists at runtime

Symbol ──▶ Var ──▶ Value
x ──▶ #'user/x ──▶ 42

Example explained

(def x 43)
x
; 43

In the code

  1. find the symbol
  2. resolve it to var #'user/x
  3. dereference var
  4. produce 42

Dig Deeper

Note symbol can refere to local binding (not a Var) or local variable

; local binding
(let [x 10]
  x)

; local variable
(fn [x] x) 

Immutability

Vars are mutable
The values they point are (usually immutable)

The var can change what it points to the data/vale cannot change

Imagine a program without vars
we have immutable values but no way to give them names

Vars and namespaces

Namespace is mapping from symbols to vars
Namespace is object that contains vars

(ns math)
; internaly
; pi --> var
; inc --> var

Referring to another namespace

(ns animals)
(def dog "Labrador")

(ns zoo
  (:require [animals]))

animals/dog ;; 


(ns zoo-2
  (:require [animals :as a])) ; aliases

a/dog ; shorthand for animals/dog


(ns zoo-2
  (:require [animals :refer [dog]])) ; refering vars

dog ; shorthland for a/dog

Dereferencing

Var fully qualified name includes namespace: math/pi or user/x

Example

(def x 42)

When evaluating steps

  1. find var
  2. deref var
  3. 42

you can also do dereferencing by:

@#'x 
; or
(deref #'x)

Evaluation pipeline simplify

Examples

(double-it x)
  1. Soruce code contains symbols (double-it x)
    Symbols is name ('x, it has type clojure.lang.Symbol)

  2. Compiler resolve namespace
    What does the symbol x refer to in the current namespace
    So we get namespace-name/x var
    Note: namespace dosen't store values it stores vars

  3. Var is runtime object so it dereferences it to get values

  4. Invocation (of the function or the value)

Why we have vars, what not directly have values, because with vars we have

  • redefintion (REPL)
  • metadata
  • dynaming binding

Dig Deeper

Special Symbols

' — Quote

Dont evaluate this treat it as data

Example

(def x 42)

This means

  1. Resolve symbol
  2. Find the var
  3. Return it value
'x
; => x

This means

  1. Return symbol

#' — Var Quote

Resolve this symbol to its Var

Example

(def x 42)
#'x
;; => #'user/x

The result is not 42
the result is the Var object
To get a value you can dereference it using @

@#'x
;; => 42
; or

(deref #'x)

When to use it

  • Metadata is attached to the Var
  • Testing and mocking
  • Implementing macros
  • Dynamic Vars and advanced runtime features

Permalink

AI is ruining self-learning

The thing I hate most about AI is that it has made it very difficult to actually learn something new anymore. I have become convinced that if you already know what you are doing, AI can be quite cool. It definitely allows me to develop more and better software than ever before (which I&aposm not convinced yet is necessarily a good thing, but it is cool). The problem is, though, if you don&apost know what you&aposre doing, AI has made it more or less impossible to tell what is true or not anymore. This is actually a huge problem for learning. If you don&apost know what you don&apost know, how can you possibly sift through the sheer volume of slop that has flooded the internet on whatever topic you&aposre trying to learn about? This is actually a frustrating problem.

Permalink

AI agents, Haskell, and the stubbornness of developers

Recently, I found an article about a member of the Haskell Foundation – who was moving his company away from Haskell. The company built a hardware product with Haskell, and they were moving away reluctantly.

I’m not going to repeat the whole thing here (you can read it yourself, but the TL;DR; is: the Haskell compiler and tooling ecosystem was too slow, and that became a bottleneck now that the LLM agent writing the code is so fast.

Now, let’s be clear: I don’t want to start a holy war about “static versus dynamic typing”, so I’m not going to do that. I’m not going to discuss if the merits of static typing are overrated or not, and I’m not going to discuss if dynamic languages are more or less flexible. This was discussed over and over again – and nobody knows the result. And for me, that in-determination is also a result.

What I am going to talk about is the stubbornness of software developers. It’s something that’s been bothering me for a couple of years now, and it still bothers me today. Looking at this article was no different.

The bottleneck is the compiler

So, again, the big TL;DR; of the article: the Haskell tooling is too slow, and the agents are really fast to write code.

If an agent produces working code in, say, 20 seconds, and the Haskell compiler takes 5, 10, 15 minutes to compile… then the compiler becomes a huge bottleneck in the loop where the agent writes code, tests it, sees if it works, and rewrites it to iterate. The agent is done, and the Haskell compiler still didn’t finish.

And here’s the part that got my attention: the author moved from Haskell to Python.

This seems like a weird move to me, because they’re moving from a static language to a dynamic one. But the thing is – it also matches my own experience (I also started with some static typed language, and moved to Ruby/Clojure). And here’s the stubbornness part: this is something that people who like dynamic languages (like me) have been saying for literal years – He experienced something that we iterate, and explain, and prove, again and again and people are still fighting to believe us.

It’s not the LLM

So what’s different right now? It’s not the LLM.

Even supposing the LLM could write excellent code – or even good code (which, to be honest, has not been my experience – It’s mostly code that works, but that I would not write: full of defensive coding, and sometimes even duplications), the real difference between an LLM and a human is: a human programmer is usually proficient in 3, 4, 5 languages, and plenty of programmers are proficient in one or two at most.

The agent, on the other hand, is proficient in multiple languages, and it writes with the same code quality in all of them. Be it good or bad, it’s the same. And that is the interesting part – you now have a mythical “person” (a mythical robot, maybe) – that can produce, with the same ease and the same quality, code in both Haskell and Python: which means you can finally compare the two while holding the developer constant.

And what they found is that the number of defects in the Python code, over a given timeline, was the same as the number of defects in the Haskell code over that same timeline. (Probably fewer in Python, actually – otherwise why would they be moving away from Haskell? But I’m not going to guess). Uhnnn… who could have predicted, except the academic study that studied that in 2016, and that ANOTHER study in 2019 found the same conclusion?

What about quality?

So here’s what makes a good quality software: it’s the developer. And in this case, the machine is the same developer producing the same quality code in two languages. Which means they can measure how much the compiler was actually influencing the time it takes to write something – and what they found is that the compiler wasn’t helping them as much as you’d expect.

I am not going to be that guy and say that the compiler wasn’t helping at all – maybe it was – but the actual time to produce code of quality X was the same in both a compiled, static language like Haskell and a dynamic, interpreted one like Python.

And this, again, matches my experience.

The number of hours that I saw people spend producing correct, and quality code in C# and Java was the same – if not higher – than the time I saw people doing quality code in Ruby. Yes, sure: if you did a very bad Ruby implementation, it was very, very hard to read. But I also saw very, very hard to read C# and Java code. The type system helped me understand what the code was doing – but it did not help me write the fixed version because, for that, I usually had to change the type of some function… and changing that type would cascaded compilation errors over and over the codebase, forcing me either to write “adapter”, or “facades” or other techniques to fix the issue (again, structural typing might help but not by much – you can have the proliferation of very small, specific types that you need to merge and split, essentially making the whole thing a duck-typed code anyway)

Also, if I am writing new types, cascading it over the code, can I actually guarantee the same code quality? After all, am I not changing the shape of the data I’m sending, essentially invalidating the whole “this data have this structure and we’re sure of it”?

Dehumanization, again

You know what bothers me? I met people that worked in Haskell. Most of them would not accept, at all, any argument for a language with “weaker typing” (I wrote how that term is meaningless in the past, so I’m also using it in a meaningless way, just to be 100% sure) than Haskell – some would argue that we need more types and that it’s impossible to guarantee any quality in languages Clojure (because it’s dynamic) and even worse in Ruby or Python (because they are imperative). Few people actually wanted to listen to a different approach, to listen to the “Clojure guy” (usually that was me, before I quit most of the groups because they ended up discussing into theorems, category algebra, etc and less about software) about some different approach to solve a problem.

But… apparently, a machine is ok.

And the worst part? It’s the honeymoon phase. They are doing what LLMs are very good at – capturing a code that already exists, that have very well-define semantics, and porting that to another language. The LLM will probably be able to solve some issues on that migration, and probably will offer a good path for the future.

And that will be attributed to the machine – not the humans that wrote the first Haskell code, well-written, in the first place.

Exactly like they listened to the machine saying that “A dynamic language can produce code faster, and we can iterate sooner on that result” than us, developers of said language saying the exact same thing over the years.

Permalink

Things I wish Datomic had: Map values

Whence this post

JEP 401 – a proposal to add value classes to Java – has been crystallizing for almost six years now, but I only learned about it this morning. I skimmed it, nodding along to myself as I thought “gee, I can’t imagine going back to Java” and “wonder how this might make Clojure more performant?”. But then I thought about some Datomic (actually, Datahike) data remodelling that I’d been working on recently, and I realized that the two areas are connected.

So here’s a braindump, in an effort to clear up my mental image of all this.

A simple example

Consider this Clojure value:

(def prog
  {:program/name    "Apache Maven"
   :program/url     "https://maven.apache.org"
   :program/version {:version/major 3
                     :version/minor 9
                     :version/patch 16}})

If you’re like me, you’ll have a warm, comfortable feeling in your heart looking at this. Plain data at rest. Accessible, transformable. What’s not to love?

Well, now try storing it in Datomic. Easy! Let’s first define a schema:

(def schema
  [{:db/ident :program/name
    :db/valueType :db.type/string
    :db/unique :db.unique/identity
    :db/cardinality :db.cardinality/one}
   {:db/ident :program/url
    :db/valueType :db.type/uri
    :db/cardinality :db.cardinality/one}
   {:db/ident :version/major
    :db/valueType :db.type/long
    :db/cardinality :db.cardinality/one}
   {:db/ident :version/minor
    :db/valueType :db.type/long
    :db/cardinality :db.cardinality/one}
   {:db/ident :version/patch
    :db/valueType :db.type/long
    :db/cardinality :db.cardinality/one}
   {:db/ident :program/version
    :db/valueType :db.type/ref
    :db/isComponent true
    :db/cardinality :db.cardinality/one}])

And now we can transact it (I’ll assume we have a DB connection, conn):

@(d/transact conn schema)
@(d/transact conn [prog])

Done. Now we can check what Maven’s version is:

(let [db (d/db conn)
      mvn (d/entity db [:program/name "Apache Maven"])]
  (:program/version (d/touch mvn)))
;=> {:db/id 17592186045419, :version/major 3, :version/minor 9, :version/patch 16}

It works!

Meh

But I’m not exactly happy about this.

What I’d really like to get is #:version{:major 3, :minor 9, :patch 16}. But Datomic models maps as entities – focal points that bind attributes with values. That’s fine for the top-level program map, but version is not an entity! It’s just a value, like a number or a string. It has internal structure, but, conceptually, it’s just an atomic value, an element of the set of all possible major.minor.patch version numbers.

Yet Datomic forces us to “reify” the version map as an entity. That means that it automatically gets a :db/id. If a new version of Maven gets released, and we transact that fact:

@(d/transact conn
             [[[:program/name "Apache Maven"]
               :program/version
               #:version{:major 3, :minor 9, :patch 17}]])

then Datomic will create a fresh artificial entity, give it the three version attributes, and associate it with the Maven entity. But the old one still exists in the DB! It’s “semi-orphaned” (detached from the rest of the object graph in the current state, but still reachable via history). If Maven for some reason goes back to 3.9.16 via a similar transaction, then we’ll get a third entity that is a duplicate of the first one.

Even worse, there’s nothing stopping us from changing attributes of that entity:

(let [db (d/db conn)
      mvn (d/entity db [:program/name "Apache Maven"])]
  @(d/transact conn [[(-> mvn :program/version :db/id) :version/major 4]]))

Now the identity of version hasn’t changed, but Maven is at 4.9.17, and every other entity that happened to be referencing the artificial version one is at 4.9.17 too! Clearly, this kind of thing should be disallowed.

Also note that I had to say :db/isComponent true in the schema for the transaction to have succeeded at all. isComponent means that the child entity only makes sense in the context of parent; otherwise, I’d have to lift the version map to the top-level of the transaction, give it a temporary id, and use that id to refer to it in the program map.

Two kinds of maps

At this point, I realize there are two kinds of maps: those that describe snapshots of state of some stateful entity at some point in time (like program), and those that are merely juxtapositions of named values (like version). For want of better names, for now on I’ll call these “snapshot maps” and “plain maps”, respectively.

Quoting from Clojure’s Approach to Identity and State:

We need to move away from a notion of state as "the content of this memory block" to one of "the value currently associated with this identity". Thus an identity can be in different states at different times, but the state itself doesn’t change. That is, an identity is not a state, an identity has a state. Exactly one state at any point in time. And that state is a true value, i.e. it never changes.

With this in mind, we could reformulate the distinction as follows: “snapshot maps” are the state of some identity, while “plain maps” are not.

In Clojure, the two kinds of maps look and work exactly the same, but as we saw, they are conceptually very different. It is typically obvious which kind a given map belongs to; e.g. snapshot maps typically have a :id field that holds an integer or a UUID. But sometimes the exact same map can be viewed as either (is {:x 1 :y 2} just a 2D point – a plain map – or a snapshot of a point moving around – a snapshot map?) It’s not just maps either; we could make the same distinction for vectors, or indeed scalar values, but maps are where it’s most commonly encountered.

I find the distinction philosophically interesting. For example, could we envision a variant of Clojure that makes it explicit? Or discriminate between them based on the value’s metadata? Should deref automatically set that metadata? Should operations on the map preserve that metadata?

Besides semantic version numbers, here are some more examples of plain maps that appear frequently:

  • Temporal values. These are typically disguised as instances of java.util.Date or java.time.* classes, but are conceptually immutable collections of fields. For example, java.time.Instant values look like {:epochSecond long, :nano int}.
  • Prices, consisting of an amount and a currency. Back when Fy! used Datomic, we modelled prices as “artificial entities” as described in this post, leading to a massive proliferation of duplicate/orphaned price entities accruing in everyday operation.

What else could we do?

Going back to Datomic: if we want to avoid artificial entities, what alternatives do we have?

  • Store them as strings instead, like "3.9.16". Strings are plain values and they map cleanly onto how we typically denote versions. This may be the right solution if our app also presents versions this way and doesn’t do any fancy processing of it. A potential problem is that strings don’t sort correctly as versions: "11.0.2" is lexicographically smaller than "2.2.1", so the values will appear in an incoherent order in the AVET index. If we want to extract programs with versions in a certain range, the index can’t help us – we’ll need to do a full scan of the DB, then parse and sort manually.

  • Store them as tuples instead, like [3 9 16]. Datomic supports short vectors as values that don’t have an identity. In our case, we can represent versions numbers as 3-tuples of longs (either homo- or heterogeneous ones will do), [major minor patch]. This gives us the correct sorting. The downside is that the rest of our app probably doesn’t think about versions this way, so we need to translate the “domain” values that we use elsewhere to tuples before transacting, and translate it back in the data access layer.

    This leads to increased verbosity (one of the nice things about Datomic is that, most of the time, it lets you get away without having a data access layer at all, as you can use the entities returned by d/entity directly in your domain logic code). Still, I think it’s the right thing to do.

  • Flatten the data: don’t have the :program/version attribute at all, and instead associate the :version/* attributes directly with the program entity. This may make sense in some cases, but loses grouping, which can make it harder to programmatically process such data. Also, what if the program could have many versions?

  • Use Datahike instead of Datomic, and use its unstructured input feature in the “content identity” mode. This still creates artificial entities, but avoids duplication because it automatically infers ids from the map content, so two maps with the same content will refer to the same entity. However, it still has the “accidental mutability” problem with the artificial entity. You have to either opt in to content identity for all sub-maps of the map being transacted, or opt out of it en masse. Plus, it just feels hacky.

In an ideal world…

…I’d like to be able to just define new types in Datomic. Just like there’s built-in supports for java.util.Dates (as :db.type/inst) or java.net.URIs (as :db.type/uri), I’d like to somehow tell Datomic to “support instances of java.time.LocalDate as :db.type/date”. Or, “let :db.type/semver be a map mapping :version/major, :version/minor, and :version/patch to longs”.

How such an API could look like is open to discussion: I don’t have concrete ideas here. Whatever the design, though, I think it’d be a win.

Permalink

Database adapters in Biff 2

I've released two new Biff libraries, both database adapters: biff.sqlite and biff.xtdb. Both of them implement some interfaces used by various other Biff libraries, and they also both implement additional functionality that can be useful to Clojure apps even if they aren't using Biff.

The interfaces

So far, modifying a Biff app to use a different database than the default has been kind of inconvenient, as a result of philosophy about modularity for Biff 1. The ability to swap out defaults was more of an escape hatch so that starting out with Biff doesn't mean your locked into all its choices forever. So that meant if you wanted to swap out the database, you had to, for example, copy and paste all of Biff's sign-in-via-email code and rewrite the queries.

With Biff 2, modularity is becoming more of a first-class citizen. Hence these new "interfaces." And the main interface here addresses the question of, you know, how do you put things in a database. And get them back out. For Biff 2, I wanted be able to package up shared application functionality that needs persistence (such as the authentication module) in a database-agnostic way.

So Biff 2 now defines a key-value store interface which can be implemented by database adapter libraries like the two I've just released:

And my two implementations: sqlite and xtdb. These KV-store functions are exposed via a biff.core module, for example.

In general I try to avoid adding layers to things unnecessarily, so it took a little thinking to arrive at this solution. My main thought was that I'm primarily interested in database-agnosticism for libraries, not applications. Migrating a single application's database is a different use case than wanting to provide functionality for multiple applications using different databases, and it's the latter use case that I'm trying to address.

So I didn't want to introduce some sort of "Biff query/transaction language" that you'd use whenever writing a Biff app; that would be overkill. The database-agnostic libraries I wanted to write have only basic persistence needs, so a key-value interface is sufficient. And that's an easy interface for database adapters to implement.

The second biggest interface-type-thing is that both adapters come with make-resolvers functions (sqlite / xtdb) which generate a set of biff.graph resolvers for the tables in your application schema.

The features

Besides the key-value functions and a couple other doodads, the remaining functionality in these database libraries is just whatever stuff I thought would be nice to have when writing a Clojure app using the respective databases. From biff.sqlite's README:

  • Sane defaults like WAL mode, STRICT tables, etc.
  • Backup/restore via Litestream.
  • Migrations via sqldef.
  • Rich schema types: define columns as e.g. booleans, instants, nested maps, etc; and biff.sqlite converts them to/from SQLite's supported types (ints, blobs, etc).
  • Validate transactions based on centralized authorization rules you define (helps to keep LLM code secure).

And for biff.xtdb:

  • Start up an in-process node with high-level config defaults.
  • Custom :biff/upsert and :biff/assert-unique transaction operations.
  • Optionally enforce Malli schemas on write.
  • Define centralized authorization rules for validating transactions.

biff.sqlite is a chonkier library due to all the logic needed for supporting rich schema types.

Write your own database adapter

Finally, I have written a database adapter guide which lists all the interfaces that adapters should implement and suggests additional features that may or may not be relevant for any given database. Between that guide and the two sqlite/xtdb reference implementations, I'm hoping it should be straightforward to implement an adapter for whatever database you want to use. I will probably only maintain the SQLite and XTDB adapters, but I might publish some as-is code for other databases which could be picked up and maintained anyone who so chooses.


Plug: my team is hiring for a senior software engineer, writing ClojureScript and Python. We make optimization software for clean energy projects.

Permalink

Python Fundamentals for a JavaScript Developer

I'll guide you through Python fundamentals by comparing concepts with JavaScript. Let's start!

1. Hello World & Basic Syntax

JavaScript

console.log("Hello World");
let x = 5;

Python

print("Hello World")
x = 5  # No semicolon, no let/const

Key Differences:

  • No semicolons in Python
  • Indentation matters (replaces curly braces)
  • Comments use # instead of //

2. Variables & Data Types

JavaScript

let name = "Alice";  // string
let age = 30;        // number
let isStudent = true; // boolean
let scores = [95, 87, 91]; // array
let person = {        // object
    name: "Bob",
    age: 25
};
let nothing = null;
let notDefined = undefined;

Python

name = "Alice"        # str
age = 30              # int (or float for decimals)
is_student = True     # bool (capital T/F)
scores = [95, 87, 91] # list (mutable)
person = {            # dict (dictionary)
    "name": "Bob",
    "age": 25
}
nothing = None        # Python's null/undefined

Key Differences:

  • Python uses snake_case (not camelCase)
  • True/False capitalized
  • None instead of null/undefined
  • Lists ≈ Arrays, Dicts ≈ Objects

3. Control Flow

JavaScript

// If-else
if (age >= 18) {
    console.log("Adult");
} else if (age >= 13) {
    console.log("Teen");
} else {
    console.log("Child");
}

// For loop
for (let i = 0; i < 5; i++) {
    console.log(i);
}

// While loop
let count = 0;
while (count < 5) {
    console.log(count);
    count++;
}

Python

# If-else (indentation instead of braces)
if age >= 18:
    print("Adult")
elif age >= 13:  # NOT else if
    print("Teen")
else:
    print("Child")

# For loop (more like for...of in JS)
for i in range(5):  # range(5) = [0, 1, 2, 3, 4]
    print(i)

# Iterate over list (like for...of)
for score in scores:
    print(score)

# While loop
count = 0
while count < 5:
    print(count)
    count += 1  # No ++ operator in Python

4. Functions

JavaScript

// Function declaration
function add(a, b) {
    return a + b;
}

// Arrow function
const multiply = (a, b) => a * b;

// Default parameters
function greet(name = "Guest") {
    return `Hello ${name}`;
}

Python

# Function definition (def instead of function)
def add(a, b):
    return a + b  # Indented body

# Lambda functions ≈ Arrow functions
multiply = lambda a, b: a * b

# Default parameters
def greet(name="Guest"):
    return f"Hello {name}"  # f-strings like template literals

# Multiple return values (tuples)
def get_coordinates():
    return 10, 20  # Returns a tuple (10, 20)

x, y = get_coordinates()  # Destructuring assignment

5. Data Structures Comparison

Arrays/Lists

// JavaScript Arrays
let arr = [1, 2, 3];
arr.push(4);          // [1, 2, 3, 4]
arr.pop();            // [1, 2, 3]
let sliced = arr.slice(0, 2);  // [1, 2]
# Python Lists
arr = [1, 2, 3]
arr.append(4)         # [1, 2, 3, 4]
arr.pop()             # [1, 2, 3] (removes last)
sliced = arr[0:2]     # [1, 2] (slicing syntax)
arr.insert(1, 99)     # [1, 99, 2, 3]

# List comprehension (unique to Python)
squares = [x**2 for x in range(5)]  # [0, 1, 4, 9, 16]

Objects/Dictionaries

// JavaScript Objects
let person = {
    name: "Alice",
    age: 30,
    greet() {
        return `Hello, I'm ${this.name}`;
    }
};
console.log(person.name);
console.log(person["age"]);
# Python Dictionaries
person = {
    "name": "Alice",
    "age": 30,
    "greet": lambda self: f"Hello, I'm {self['name']}"
}
print(person["name"])  # Access with brackets
print(person.get("age"))  # Safer access

# Methods don't naturally have 'this' context
# Usually you'd use classes for that (see below)

6. Classes & OOP

JavaScript (ES6+)

class Person {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }

    greet() {
        return `Hello, I'm ${this.name}`;
    }

    static species = "Human";
}

const alice = new Person("Alice", 30);

Python

class Person:
    species = "Human"  # Class attribute (static)

    def __init__(self, name, age):  # Constructor
        self.name = name  # Instance attribute
        self.age = age

    def greet(self):  # Methods always have self parameter
        return f"Hello, I'm {self.name}"

    @staticmethod
    def static_method():
        return "This is static"

alice = Person("Alice", 30)
print(alice.greet())  # No parentheses needed for self when calling

7. Modules & Imports

JavaScript (ES6 Modules)

// math.js
export const PI = 3.14159;
export function add(a, b) { return a + b; }

// main.js
import { PI, add } from './math.js';
import * as math from './math.js';

Python

# math.py
PI = 3.14159
def add(a, b):
    return a + b

# main.py
from math import PI, add
import math  # Then use math.PI, math.add
import math as m  # Alias

8. Error Handling

JavaScript

try {
    throw new Error("Something went wrong");
} catch (error) {
    console.error(error.message);
} finally {
    console.log("Cleanup");
}

Python

try:
    raise Exception("Something went wrong")
except Exception as e:  # 'as' instead of variable declaration
    print(f"Error: {e}")
finally:
    print("Cleanup")

9. Async Programming

JavaScript (Promises/Async-Await)

// Promise
fetch('https://api.example.com/data')
    .then(response => response.json())
    .then(data => console.log(data));

// Async/await
async function getData() {
    const response = await fetch(url);
    return await response.json();
}

Python (Async/Await)

import asyncio
import aiohttp  # External library for HTTP

async def fetch_data(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            return await response.json()

# Run async function
asyncio.run(fetch_data('https://api.example.com/data'))

10. Common Patterns & Tips

1. Type Checking (Python is dynamically typed but has type hints)

def add(a: int, b: int) -> int:  # Type hints (optional)
    return a + b

2. String Formatting (multiple ways)

name = "Alice"
# f-strings (Python 3.6+, like template literals)
print(f"Hello {name}")

# .format() method
print("Hello {}".format(name))

# % formatting (older style)
print("Hello %s" % name)

3. Tuple vs List

# List - mutable
my_list = [1, 2, 3]
my_list[0] = 99  # OK

# Tuple - immutable
my_tuple = (1, 2, 3)
# my_tuple[0] = 99  # ERROR!

4. Sets (unique unordered collection)

my_set = {1, 2, 3, 3, 2}  # {1, 2, 3} (duplicates removed)
another_set = set([1, 2, 3, 4])  # Alternative creation

Quick Reference Table

JavaScript Python Notes
let x = 5; x = 5 No declaration keywords
const arr = [] arr = [] No const, just assign
null / undefined None Single null value
=== strict equality == and is == value, is identity
array.length len(list) Function, not property
array.map() List comprehensions [x*2 for x in arr]
for (let i=0; i<n; i++) for i in range(n) Different pattern
function fn() {} def fn(): def keyword
obj.property dict["key"] or obj.attr Depends on type
class MyClass {} class MyClass: Colon and indentation

Practice Exercise

Convert this JavaScript code to Python:

function filterEvenSquares(numbers) {
    return numbers
        .filter(n => n % 2 === 0)
        .map(n => n ** 2);
}

const result = filterEvenSquares([1, 2, 3, 4, 5]);
console.log(result); // [4, 16]

Python solution:

def filter_even_squares(numbers):
    return [n**2 for n in numbers if n % 2 == 0]

result = filter_even_squares([1, 2, 3, 4, 5])
print(result)  # [4, 16]

Next Steps

  1. Install Python and a good IDE (VS Code with Python extension works well)
  2. Practice by rewriting your JS projects in Python
  3. Explore Python-specific features: decorators, generators, context managers
  4. Learn popular libraries:
    • Web: Flask/Django (Express equivalents)
    • Data: NumPy, Pandas
    • AI/ML: TensorFlow, PyTorch

The main mindset shift: Python emphasizes readability and simplicity over cleverness. You'll write less code to accomplish the same tasks!

Permalink

Looking for work

Hey, Niki here. This is a bit unusual. My sabbatical is coming to an end, and I am looking for a new opportunity. Full-time or contract, startup or research, remote or Berlin, individual contributor, ideally—tight team, ambitious product.

I am a software engineer first and foremost with 20+ years of experience. I work on technically challenging products, foundational technology, dev tools. I’ve been doing Clojure and web recently, but I'm also very excited to explore closer-to-the-metal programming.

I have an eye for design, user interfaces, UX, DX. I would love to work with a team that takes interface quality seriously. Or to work with graphics!

I am pretty sure I am good at explaining stuff, including what we are building, why, why this way, why is it important, etc. For example.

The overarching theme is to understand computers deeply, and then use that to make better and simpler software. If you care about that too, we might be a great match!

Recent work

Instant DB is a US startup building a modern Firebase. I worked on the sync algorithm, performance, DX. A summary of my commit log.

Roam Research is an OG personal knowledge manager. I worked on database optimization and a plugin system.

At JetBrains, I developed a new Skia renderer for Fleet and Jetpack Compose Desktop.

I’ve built many open-source libraries, including a database, a GUI toolkit, a Clojure dev environment, a React wrapper, a well-known font... More recently, Clojure+ gives you a taste of my approach to DX, and Fast EDN—to performance.

I maintain several active projects — AlleKinos.de, Grumpy Website, this site.

If you want to dive deeper, here’s the usual stuff: Projects / Talks / LinkedIn / GitHub

I also made a two-page PDF CV.

Why this post?

It’s an attempt to reach beyond my immediate network. I’ve been doing Clojure for a long time, and now want to explore.

If you are working on a compiler, a database, an IDE, a programming language or another technically ambitious product, touching graphics, typography, algorithms, low-level programming, and you think my experience can help, let’s talk: niki@tonsky.me.

Permalink

Statistics made simple

I have a weird relationship with statistics: on one hand, I try not to look at it too often. Maybe once or twice a year. It’s because analytics is not actionable: what difference does it make if a thousand people saw my article or ten thousand?

I mean, sure, you might try to guess people’s tastes and only write about what’s popular, but that will destroy your soul pretty quickly.

On the other hand, I feel nervous when something is not accounted for, recorded, or saved for future reference. I might not need it now, but what if ten years later I change my mind?

Seeing your readers also helps to know you are not writing into the void. So I really don’t need much, something very basic: the number of readers per day/per article, maybe, would be enough.

Final piece of the puzzle: I self-host my web projects, and I use an old-fashioned web server instead of delegating that task to Nginx.

Static sites are popular and for a good reason: they are fast, lightweight, and fulfil their function. I, on the other hand, might have an unfinished gestalt or two: I want to feel the full power of the computer when serving my web pages, to be able to do fun stuff that is beyond static pages. I need that freedom that comes with a full programming language at your disposal. I want to program my own web server (in Clojure, sorry everybody else).

Existing options

All this led me on a quest for a statistics solution that would uniquely fit my needs. Google Analytics was out: bloated, not privacy-friendly, terrible UX, Google is evil, etc.

What is going on?

Some other JS solution might’ve been possible, but still questionable: SaaS? Paid? Will they be around in 10 years? Self-host? Are their cookies GDPR-compliant? How to count RSS feeds?

Nginx has access logs, so I tried server-side statistics that feed off those (namely, Goatcounter). Easy to set up, but then I needed to create domains for them, manage accounts, monitor the process, and it wasn’t even performant enough on my server/request volume!

My solution

So I ended up building my own. You are welcome to join, if your constraints are similar to mine. This is how it looks:

It’s pretty basic, but does a few things that were important to me.

Setup

Extremely easy to set up. And I mean it as a feature.

Just add our middleware to your Ring stack and get everything automatically: collecting and reporting.

(def app
  (-> routes
    ...
    (ring.middleware.params/wrap-params)
    (ring.middleware.cookies/wrap-cookies)
    ...
    (clj-simple-stats.core/wrap-stats))) ;; <-- just add this

It’s zero setup in the best sense: nothing to configure, nothing to monitor, minimal dependency. It starts to work immediately and doesn’t ask anything from you, ever.

See, you already have your web server, why not reuse all the setup you did for it anyway?

Request types

We distinguish between request types. In my case, I am only interested in live people, so I count them separately from RSS feed requests, favicon requests, redirects, wrong URLs, and bots. Bots are particularly active these days. Gotta get that AI training data from somewhere.

RSS feeds are live people in a sense, so extra work was done to count them properly. Same reader requesting feed.xml 100 times in a day will only count as one request.

Hosted RSS readers often report user count in User-Agent, like this:

Feedly/1.0 (+http://www.feedly.com/fetcher.html; 457 subscribers; like FeedFetcher-Google)

Mozilla/5.0 (compatible; BazQux/2.4; +https://bazqux.com/fetcher; 6 subscribers)

Feedbin feed-id:1373711 - 142 subscribers

My personal respect and thank you to everybody on this list. I see you.

Graphs

Visualization is important, and so is choosing the correct graph type. This is wrong:

Continuous line suggests interpolation. It reads like between 1 visit at 5am and 11 visits at 6am there were points with 2, 3, 5, 9 visits in between. Maybe 5.5 visits even! That is not the case.

This is how a semantically correct version of that graph should look:

Some attention was also paid to having reasonable labels on axes. You won’t see something like 117, 234, 10875. We always choose round numbers appropriate to the scale: 100, 200, 500, 1K etc.

Goes without saying that all graphs have the same vertical scale and syncrhonized horizontal scroll.

Insights

We don’t offer much (as I don’t need much), but you can narrow reports down by page, query, referrer, user agent, and any date slice.

Not implemented (yet)

It would be nice to have some insights into “What was this spike caused by?”

Some basic breakdown by country would be nice. I do have IP addresses (for what they are worth), but I need a way to package GeoIP into some reasonable size (under 1 Mb, preferably; some loss of resolution is okay).

Finally, one thing I am really interested in is “Who wrote about me?” I do have referrers, only question is how to separate signal from noise.

Performance. DuckDB is a sport: it compresses data and runs column queries, so storing extra columns per row doesn’t affect query performance. Still, each dashboard hit is a query across the entire database, which at this moment (~3 years of data) sits around 600 MiB. I definitely need to look into building some pre-calculated aggregates.

One day.

How to get

Head to github.com/tonsky/clj-simple-stats and follow the instructions:

Let me know what you think! Is it usable to you? What could be improved?

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.