Deleting 49 Python Files: What the Babashka Rewrite Actually Proved
I deleted 49 Python files from an automation stack and replaced them with 34 Clojure files on Babashka. The surprise was not the language change — it was that the rewrite was never about speed, and that my strongest verification gate had been quietly reporting green on provably red probes for days.
The system being replaced
One operator, one metric: money in a bank account. The stack sweeps a national job board’s public JSON API, scores each posting against verified criteria, builds tailored resume packets, and submits them through the browser because the board exposes no apply API. A guard enforces one touch per contact, permanently. A status page reports process health and queue depth.
| Stage | Implementation before | Implementation after |
|---|---|---|
| Sweep + score | 3 near-duplicate fetch loops, each with its own timeout | 1 client namespace, error-as-data returns |
| Contact guard | 7-day re-send window | contacted-once means forever |
| Recruiter send | sent on every run | dry run unless --send is passed |
| Browser submit | unvalidated, assumed working | throws until a logged-in run proves the selectors |
| Status page | “stopped” meant paused or broken | health table distinguishes the two |
| Tests | 67 pytest cases | 303 assertions + 35 self-tests |
Three claims I refused to write down
The rewrite brief I was handed asserted three things. All three were false, and I had the evidence to reject them before writing the first file.
| Claim | Verdict | Evidence |
|---|---|---|
| Line-based patch tools fail on Python indentation | False | Not one recorded defect in the tree’s history was an indentation or patch-match failure. Every one was a path, a scope, a liveness check, or a return shape. |
| Babashka starts ~10 ms vs Python ~100 ms | Backwards | Three cold runs each on the migration machine: Babashka 16-18 ms,
python3 10-12 ms. |
| S-expressions cut token cost | False during migration | 6,236 lines written to replace 7,509. A rewrite pays one full read plus one full write per file. |
The startup numbers matter because they were the load-bearing justification. Both runtimes are noise next to a 25-second sweep, a 2-second probe timeout, or a 250 ms polite delay between API calls. Any post claiming a startup win here is selling something.
Six batches, one rollback point
Every rung is a tagged commit where both trees still run. The Python files were not deleted on a schedule; deletion was gated on the Babashka side going green first.
| Batch | Scope | Gate before the next rung |
|---|---|---|
| 0 | Preflight tag, bb.edn, parity skeleton |
tag exists, both trees runnable |
| 1 | config.clj, profile.clj |
value parity on both |
| 2 | scoring, guard, mailer, JSON boundary | guard 16/16 identical |
| 3 | CLI entry points, HTML report | report bytes identical on a real packet |
| 4 | CDP frames, SSE server | server verified on an isolated port |
| 5 | cutover, CI rewrite | 303 assertions green, then delete |
The parity gate is the real deliverable
The strongest thing in this migration is not the Clojure. It is a gate that runs both implementations on the same inputs and diffs the values.
| Probe | Result at handover |
|---|---|
| config | byte-identical, 4,206 bytes |
| profile | byte-identical, 9,567 bytes |
| contact guard | 16 of 16 cases identical |
| shortlist ordering | 123 of 123 lines identical |
| report HTML | byte-identical on a real packet |
A “pure move” that changes a constant, a keyword list, or a regex cannot pass this. The gate then shipped four silent failures of its own, each documented in its own header:
- An options map splatted into argv as three strings, so the diff received it as operands and wrote nothing.
:out :stringreturns a future in Babashka, so stringifying it yielded""and every comparison compared empty to empty.- “Identical” was decided from empty diff text rather than the process exit code.
- An entry guard that never fired: exit 0, no output, no failure.
A gate that cannot fail is worse than no gate. The fix was 35 assertions in a self-test suite that breaks things on purpose and requires the gate to scream.
Where the invariants actually moved
;; One normaliser per direction. Snake on disk, kebab in memory.
(def ^:private reader-opts {:key-fn wire->kebab})
Three properties became structural rather than grep-enforced. This is the honest core of the rewrite.
| Invariant | Python enforcement | Babashka enforcement |
|---|---|---|
| one owner per constant | an audit script grepping ^FLOOR\s*= |
a def in one namespace; a second one is a different
var |
| key spelling at the boundary | hand-written mapping in N places | one module, wire->kebab and
kebab->snake |
| transport failure | exception unwinds through the caller | {:status :error :msg} — the caller decides |
The audit story is the one worth keeping. The Python audit printed STRUCTURE OK forever after the port, because its regex no longer matched anything. It was guarding an empty set, green, in CI. Clojure did not make the audit better; it made the silent version of that failure structurally unlikely.
Network layer with zero dependencies
Babashka ships no WebSocket client and no HTTP server. Both were built from JDK interop instead of adding a package.
| Need | Interop used instead of a dependency |
|---|---|
| drive the browser | Runtime.evaluate over a hand-rolled WebSocket frame —
Base64 handshake key, XOR mask on outbound frames |
| stream telemetry | java.net.ServerSocket, one accept loop,
data: {...} per tick |
| timestamps | java.time.Instant at epoch seconds |
Three defects lived in that layer, and all three failed silently:
(slurp in)on the server socket deadlocks: an HTTP client never sends EOF, it waits for the response. Read one request line instead.compare-and-set!returns a boolean, so the first event of every stream was literallydata: true.String.getByteswith four string arguments is an arity error, not concatenation. The event route closed every connection with no log line.
Edit protocol: why full-file writes
The brief also told me full-file writes beat incremental diffing because Python indentation breaks string matching. The conclusion survives; the reason does not.
| Failure mode | Python line-patch | Babashka full-file write |
|---|---|---|
| half-applied edit | valid file, wrong logic | caught by the reader at load |
| task config | n/a | EDN has no reader macros: #( fails with
No dispatch macro for: ( |
| review surface | scattered hunks | one file, one diff, one verdict |
That EDN failure is real and reproducible: adding a lambda to a task body makes the whole task file unloadable, which is a much better failure than a subtly wrong function. The protocol is worth keeping for that reason — not for indentation.
What the migration did not prove
Honesty is the point of this section, so it is a table rather than a footnote.
| Item | State |
|---|---|
| browser submit driver | never run against a live logged-in session; it throws instead of pretending |
| live sweep path | structurally verified, then rate-limited by my own probing; recovery via cron, unconfirmed at handover |
html-unescape |
two declared divergences at entity boundaries, budgeted at two diff lines so a third fails loudly |
| post-cutover parity | all five probes SKIP because the Python side is gone; that is the expected steady state |
Numbers
| Measure | Value |
|---|---|
| Python at preflight | 44 tracked files, 7,509 lines |
| Python deleted at cutover | 49 files (44 pre-existing + 5 parity probes added during the port) |
| Babashka standing | 34 files, 6,236 lines |
bb test |
80 tests, 303 assertions, 0 failures |
bb self |
35 assertions, 0 failures |
bb audit / bb verify |
structure OK / all checks passed, 1 deferred |
| Parallel test run | 4 processes, ~13 s wall |
| Test venv removed | 9.9 MB, nothing references it |
| Live service during migration | untouched; tests bound an isolated port |
| Emails sent, applications submitted by the migration | 0 |
The rewrite is worth doing for structural invariants and differential proof — not for cold start, and not for token savings.