jolt.ffi is Jolt's foreign-function interface: it loads C shared libraries and declares typed bindings over their functions, then marshals memory by hand. There is no automatic struct introspection and no garbage collection of foreign memory — you manage it, the way you would in C.
For the end-to-end guide to writing a binding — declaring the library in deps.edn, static vs dynamic linking, out-parameters, structs by offset, and binary data — see Native Interop (FFI). This page is the compact API reference for jolt.ffi itself.
(require '[jolt.ffi :as ffi])
(ffi/load-library {:darwin "libsqlite3.0.dylib" :linux "libsqlite3.so.0"})
(ffi/defcfn sqlite3-open "sqlite3_open" [:string :pointer] :int)
(with-open [arena (ffi/confined-arena)]
(let [pp (ffi/alloc arena :pointer)]
(sqlite3-open "x.db" pp)
(ffi/read pp :pointer)))
Every allocation belongs to an arena, which owns its lifetime, or to you, to free by hand. jolt.ffi matches babashka.ffi name for name and semantics for semantics wherever one substrate can match the other — see babashka.ffi compatibility for the short list of places they still differ, and why.
Binding functions
defcfnname csym argtypes rettype [:blocking]— define a foreign functionnamebound to the C symbolcsym.(sqlite3-open "x.db" pp)becomes an ordinary Clojure fn you call with Jolt values.foreign-fncsym argtypes rettype [:blocking]— the anonymous form; returns a callable instead ofdefing a name.cfn— babashka.ffi's name forforeign-fn, and the same macro.defcfntakes an optional docstring and attribute map before the C symbol, both of which land on the var, so a namespace of bindings documents itself and^:privateworks.defcfnalso has a wrapper form: a symbol after the return type names the raw binding, and the rest is an ordinaryfntail — the shape for an out-parameter or an error code callers should never see.- A trailing
:blockingmarks a call that may wait — network I/O, a lock, a sleep, a UI run loop you never return from. The call is emitted collect-safe so a thread parked inside it does not pin the garbage collector. An unmarked call stops collection process-wide for as long as it runs: other threads halt at their next allocation, far from the call responsible, while the parked thread itself looks healthy. Mark anything that can block; leave pure, fast calls unmarked.(ffi/defcfn c-connect "connect" [:int :pointer :int] :int :blocking) (ffi/defcfn c-strlen "strlen" [:string] :size_t) (ffi/defcfn sqlite3-open "Opens the database at path, storing the handle in the out-parameter." {:private true} "sqlite3_open" [:string :pointer] :int) ;; the wrapper form: the raw binding is local, the var is the friendly fn (ffi/defcfn open-db "sqlite3_open_v2" [:string :pointer :int :string] :int open-native [filename flags] (with-open [arena (ffi/confined-arena)] (let [pdb (ffi/alloc arena :pointer) code (open-native filename pdb flags nil)] (if (zero? code) (ffi/read pdb :pointer) (throw (ex-info "open failed" {:code code}))))))
Variadic C functions. :& inside the argtype vector marks the boundary: the types before it are the fixed (named) parameters, the types after it are the tail this binding passes. :varargs is Jolt's older spelling of the same marker. The call is emitted with the variadic calling convention, which Apple arm64 requires — it passes variadic arguments on the stack, and a fixed-arity binding silently corrupts them. C's default argument promotions still apply after the marker, so pass anything narrower than int as :int (and float as :double).
(ffi/defcfn c-fcntl "fcntl" [:int :int :& :int] :int)
The tail belongs to the binding, not to the call: bind one signature per tail shape. A bare :& with nothing after it — babashka.ffi's per-call tail inference — raises, because a foreign-procedure's types are fixed when it is compiled.
The trailing option may instead be a map, which is how you combine flags and how you ask for the error code:
{:blocking true}— the same as the:blockingkeyword.{:capture-native-error true}— the call returns[native-result error-code], with POSIXerrno(or WindowsGetLastError) read inside the foreign-call return path, before anything else can overwrite it. See errno for why that matters.(ffi/defcfn c-open "open" [:string :int :varargs :int] :int {:capture-native-error true}) (c-open "/definitely/not/here" 0 0) ; => [-1 2] (ffi/errno-message 2) ; => "No such file or directory"
Keys and values are validated at compile time and fail closed: an unknown key or a non-literal Boolean is an error rather than a silently ignored option. Capture needs a scalar result to pair the code with, so it is rejected on :void and on by-value struct returns.
Struct layouts
A layout compiles a literal, data-only descriptor into the ABI metadata for a C struct: its size, its alignment, and the byte offset of every field. Chez derives those from the same declarations the C compiler would, so a padded or nested struct comes out right without you counting bytes.
layoutdescriptor— compile[:struct [[field type] ...]]. Field names are unqualified keywords and must be unique; a field is a fixed-size scalar, a nested[:struct ...], or a fixed array[:array type count]. The descriptor must be a literal — it is read at compile time, not evaluated.layout-sizelayout—sizeofthe struct, padding included.layout-alignmentlayout— its alignment requirement.field-offsetlayout path— the byte offset of a field.pathis a keyword, or a vector of them to reach into a nested struct.read-fieldptr layout path/write-fieldptr layout path value— read or write a scalar field by name, at the offset the layout knows.readandwritealso take a whole layout where they take a type: a struct reads as a map of its fields and writes from one, an array reads as a vector and writes from any sequence (or Jolt array) of the declared length. A struct value must hold each field and no others.placelayout [path]— resolve one member once into a valuereadandwriteaccept where they take a type. The member decodes and encodes as its own shape, and resolving the path is the work a place removes — so make one and keep it, as you would adefcfnbinding. A path that names nothing raises rather than answeringnil: a layout is closed, so a member that is not in it is a mistake in the program.(def date (ffi/layout [:struct [[:year :int32] [:month :uint8] [:day :uint8]]])) (ffi/layout-size date) ; => 8 (6 bytes, padded to int32 alignment) (ffi/field-offset date :month) ; => 4 (ffi/with-layout [p date] (ffi/write-field p date :year 2026) (ffi/write-field p date :month 8) (ffi/read-field p date :year)) ; => 2026 ;; …or the whole struct as a map (with-open [a (ffi/confined-arena)] (let [p (ffi/alloc a date)] (ffi/write p date {:year 2026 :month 8 :day 31}) (ffi/read p date))) ; => {:year 2026 :month 8 :day 31} ;; …or one member, resolved once and reused (def year (ffi/place date :year)) (ffi/read p year) ; => 2026
A nested struct is addressed by path:
(def event (ffi/layout [:struct [[:tag :uint8]
[:when [:struct [[:year :int32] [:month :uint8]]]]
[:seq :uint16]]]))
(ffi/field-offset event [:when :year])
(ffi/read-field p event [:when :month])
A fixed array is [:array element-type count] — babashka.ffi's order — and elements may themselves be arrays or structs — so a matrix is an array of arrays, and a ring buffer of events is an array of structs. Array indices are integer components in a field path, alongside the keywords:
(def frame (ffi/layout [:struct [[:tag :int32]
[:matrix [:array [:array :double 3] 2]]
[:name [:array :uint8 8]]]]))
(ffi/layout-size frame) ; => 64
(ffi/field-offset frame :matrix) ; => 8 (the array's base offset)
(ffi/field-offset frame [:matrix 1 2]) ; => 48
(ffi/field-offset frame [:name 3]) ; => 59
(ffi/with-layout [p frame]
(ffi/write-field p frame [:matrix 1 2] 3.5)
(ffi/read-field p frame [:matrix 1 2])) ; => 3.5
;; an array of structs, indexed then named
(def q (ffi/layout [:struct [[:events [:array [:struct [[:code :int32]
[:frame :int32]]] 4]]]]))
(ffi/field-offset q [:events 2 :frame]) ; => 20
Naming the array itself (:matrix, with no index) still gives its base offset, which is what you pass to C when the function wants a pointer to the first element. Offsets for indexed paths are computed from the ABI's element stride rather than stored per element, so metadata stays one entry per declared array shape — a million-element array costs no more to describe than a two-element one.
Unions, bitfields, explicit packing, and self-referential descriptors are still not supported; lay those out by offset as before.
Structs by value
A C function that takes or returns a struct by value — not a pointer to one — is declared with [:by-value descriptor] in place of a type keyword, using the same descriptor layout takes.
An argument is a non-null pointer to caller-owned storage holding the struct's bytes; Jolt passes what it points at. An aggregate-returning function takes a destination pointer as its first Jolt argument, writes the returned struct there, and hands that pointer back. The buffer is always yours, so nothing allocates behind your back and the ownership is visible at the call site.
The descriptor is read at compile time, so it has to be written out literally at the call — a def holding one will not do, in a signature any more than in layout.
(def date (ffi/layout [:struct [[:year :int32] [:month :uint8] [:day :uint8]]]))
;; int64_t date_score(struct date d);
(ffi/defcfn score "date_score"
[[:by-value [:struct [[:year :int32] [:month :uint8] [:day :uint8]]]]] :int64)
;; struct date make_date(int32_t y, uint8_t m, uint8_t d);
(ffi/defcfn make-date "make_date" [:int32 :uint8 :uint8]
[:by-value [:struct [[:year :int32] [:month :uint8] [:day :uint8]]]])
(ffi/with-layout [d date]
(ffi/write-field d date :year 2026)
(ffi/write-field d date :month 8)
(ffi/write-field d date :day 22)
(score d)) ; => 20260822
(ffi/with-layout [out date]
(make-date out 2026 8 22) ; writes into `out`, returns it
(ffi/read-field out date :year)) ; => 2026
Nested structs, several aggregate arguments, a fixed aggregate before :varargs, and :blocking all work. A null aggregate pointer raises NullPointerException before the native call rather than faulting. Not supported: aggregate variadic arguments, an aggregate return combined with :varargs, and aggregates in foreign-callable / export! — those are rejected at compile time.
Calling back into Jolt
foreign-callablef argtypes rettype [:collect-safe]— wrap a Jolt fnfas a C-callable function pointer: the inverse ofdefcfn, so C can call back into Jolt (aqsortcomparator, a GTK signal handler, any C API that takes a callback). The args C passes arrive as Jolt values; the Jolt return is marshaled back perrettype. The callback stays live untilfree-callablereleases it. Pass a trailing:collect-safewhenever the callback can arrive on a thread that is not an active Jolt thread at that moment: either a thread the runtime never started (a dispatch queue, a pthread the C library spawned), or a Jolt thread parked in a:blockingforeign call (e.g. a GUI main loop). The collect-safe entry activates the thread before any Jolt code runs; a plain entry runs Jolt code on a thread the collector does not know is running, and the process dies with a nonrecoverable memory fault no handler can catch. The activation costs a little per call, so leave it off when C only ever invokes the callback on the thread that called into it (aqsortcomparator).free-callableptr— release a callable built byforeign-callable; returnsnil.callbackarena f argtypes rettype [:collect-safe]— the same pointer, owned by an arena, with nofree-callableto remember. Choose the arena for the thread that calls back: a shared arena lets C invoke it from any thread (an event-loop notification), a confined one is for a callback C only invokes during a call you make (a comparison function), and an automatic one releases the pointer once the arena is unreachable — the collector cannot see the copy C holds, so use it only when your own reference outlives every call C can make. C can call the pointer until its arena releases it and not one instruction longer, so unregister the callback with the C library first.export!name f argtypes rettype [:collect-safe]— publishfas a C-callable entry point undername, forjolt build --library. An embedder resolves it viajolt_lookup("name")afterjolt_library_init. The argtypes/rettype keywords are the same asdefcfn. A trailing:collect-safefollows the same rule asforeign-callable: pass it when the embedder may call the export from a thread the runtime did not start.;; qsort comparator callable into libc (def cmp (ffi/foreign-callable (fn [pa pb] (let [a (ffi/read pa :int) b (ffi/read pb :int)] (cond (< a b) -1 (> a b) 1 :else 0))) [:pointer :pointer] :int)) (c-qsort arr n (ffi/sizeof :int) cmp) (ffi/free-callable cmp) ;; …or let an arena own it (with-open [a (ffi/confined-arena)] (let [cmp (ffi/callback a compare-ints [:pointer :pointer] :int)] (c-qsort arr n (ffi/sizeof :int) cmp)))
Types
Argument and return types are keywords:
:int:uint—int/unsigned:long:ulong—long/unsigned long:int64:uint64— 64-bit integer:size_t:ssize_t—size_t/ssize_t:iptr:uptr— pointer-sized integer (handy forNULLsentinels):double:float—double/float:char—char(a code point):uint8(alias:u8,:byte) —unsigned char, number 0–255:pointer(alias:void*) — any pointer (a machine address):string—char *, marshaled both ways (UTF-8 both directions):bool— a one-byte C boolean (_Bool/stdbool.h),true/falsein Jolt. Jolt truthiness decides the byte going out, sonilandfalsesend 0 and everything else sends 1 — a C predicate answerstrue/falserather than the truthy number0. It travels as one byte, not as an int-sizedboolean, which would be the wrong width for_Booland read three bytes of whatever the callee left above the result.:void— return ignored (nil)
Memory and libraries
Foreign memory is manual — allocate, use, free. There is no finalizer.
load-library[spec]— load a shared object. With no spec, binds symbols already in the running process (libc, POSIX). With a spec, a per-OS map ({:darwin "libsqlite3.0.dylib" :linux "libsqlite3.so.0"}—:macis accepted for:darwin), a bare path, or an ordered list of candidates tried in turn, since the same library is spelled differently across distributions. Answers{:path "..."}naming the candidate that loaded. Inside adeps.ednproject you usually declare natives under:jolt/nativeinstead (see the guide).loaded?name— was a library loaded?
Symbol resolution. A library loaded through load-library or a :jolt/native declaration is loaded privately and registered: a defcfn resolves its C symbol against the registered libraries first (in load order), and only falls back to the process-global namespace when none of them export it (libc calls, :process natives). The OS's own libraries can therefore never shadow a declared native, even when they export the same symbol names — macOS ships BoringSSL system-wide with the full EVP_* set, and before this rule an OpenSSL-backed library's digest calls could silently land there and abort the process.
alloc[arena] n [alignment]— allocate zeroed memory; returns a pointer.nis a byte count, a type keyword, or a compiled layout. With an arena the arena owns it; without one youfreeit. A type or layout aligns naturally, a byte count aligns to 16, and a third argument overrides.freeptr— release memory from the arena-lessalloc/string->ptr, and forget any size recorded for that pointer (see Pointer sizes).sizeoftype-or-layout/alignoftype-or-layout— size and alignment, padding included for a layout.readptr type [offset]— read a typed value atptr(+ optional byte offset).typeis a type keyword, a compiled layout, or aplace.writeptr type value [offset]— write a typed value. **The value comes before the offset**, as it does in babashka.ffi; an offset and a value are both integers, so a call written the other way round is not detectable and writes the wrong thing.read-arrayptr n—nbytes →byte-array(binary-faithful, no encoding).read-arrayptr type n [offset]—nelements of one scalar type → an array of that width: abyte-arrayfor the one-byte types,short-array,int-arrayorlong-arrayfor the 2-, 4- and 8-byte integer and pointer types,float-array/double-arrayfor the floating types. The type gives the width and nothing else, so the bits land as they are.write-arrayptr arr/ptr arr off n—byte-array→ memory.write-arrayptr type arr [offset]— the typed inverse ofread-array.read-bytesptr n—nbytes → string (UTF-8).write-bytesptr s— a string's UTF-8 bytes → memory.string->ptr[arena] s— a NUL-terminated C string.nilanswersNULLand allocates nothing.ptr->stringptr [limit]— read a NUL-terminated C string back. Alimitbounds the scan, so a buffer with no NUL raises instead of running off the end.copysrc dst [n]— copy bytes,memmovesemantics (overlap is safe). Withoutn, the source's knownsize.clonearena src [n]— a copy ofsrcallocated inarena.null— the null pointer;null?p— the test.pointer?x— true for a non-negative integer address. A Jolt pointer is its address, so this cannot tell one from any other address-shaped integer.addressp— the address as a long (the identity on a Jolt pointer; it exists so code written against either FFI reads the same).sizep— the size Jolt was told: what an arena allocated, or whatsegment/reinterpretdeclared.0for anything else, including every pointer C hands back. This is what lets the countlesscopyandclonework.segmentaddr [n]— a pointer toaddr, recordingnas its size.slicep offset [len]— a pointeroffsetbytes intop.reinterpretp n [arena [cleanup]]— declare thatpaddressesnbytes. With an arena the declaration is dropped when the arena closes, and the arena callscleanupwithp— where a C library's own deallocator goes. The arena frees nothing here; the memory is C's.find-symbolsym— the address ofsym, ornil. Searches declared natives first, then the process's own symbols — the same resolution adefcfngets.load-system-libraryname— load by short name:"z"findslibz.so,libz.dyliborz.dll. On Linux it also globs the versioned sonames, since a runtime-only package haslibz.so.1and nolibz.so.
Nothing here is bounds-checked: a Jolt pointer is a raw address and carries no size, so reading past an allocation reads whatever is there.
Arenas
An arena is a group of allocations with one lifetime: allocate into it, and closing it releases every block, callback and registered cleanup at once.
(with-open [a (ffi/confined-arena)]
(let [buf (ffi/alloc a 4096)
name (ffi/string->ptr a "config.toml")
on-ev (ffi/callback a handle-event [:pointer :int] :void)]
...)) ; all three released here
Four kinds, differing only in who may use them and who closes them:
| usable from | closed by | |
|---|---|---|
confined-arena | the creating thread only | with-open / close-arena |
shared-arena | any thread | with-open / close-arena |
global-arena | any thread | never — it lives as long as the process |
auto-arena | any thread | the collector, once the arena is unreachable |
A confined arena is the one to reach for inside a function: it is cheapest to reason about, and using it from another thread raises rather than letting two threads race on one block list — a failure that otherwise has no error of its own, only a fault inside the allocator. A shared arena is for memory that outlives the call and is released elsewhere. An automatic arena is for the case no scope can cover: a callback C may invoke from a thread Jolt never started.
confined-arena/shared-arena/global-arena/auto-arena— construct one.close-arenaa— release everything it owns. This is whatwith-opencalls. A second close releases nothing and is not an error, so an early release inside awith-openbody does not turn into an exception thrown from thefinally. Closing from the wrong thread, or closing a global or automatic arena, raises: those are wrong rather than redundant.with-arena[a] & body—with-openwith the constructor spelled in.arena?x/arena-open?a— the predicates.drain-auto-arenas!— release every automatic arena the collector has reclaimed, and answer how many. Jolt drains when arenas are next created or allocated into, so an automatic arena is released promptly in code that keeps allocating; in code that stops, not until the process ends — which is the same memory the process was going to return anyway.
alloc, string->ptr, clone, reinterpret and callback all take an arena in the same first position. An arena releases three kinds of thing: blocks (in reverse allocation order), callbacks, and reinterpret views, whose cleanup function runs while the memory is still readable. A cleanup that raises does not strand the rest of the group — everything is released and the first failure is re-thrown.
Do not close an arena while C still holds one of its pointers. C can read released memory, and nothing raises.
Pointer sizes
A Jolt pointer is a bare machine address — it carries no length. size answers what Jolt was told a pointer addresses, which is what lets copy and clone work without a byte count:
sizep— the recorded size, or0for a pointer Jolt was never told about (every pointer that comes back from C).segment[arena] addr [nbytes]— a pointer toaddr, recordingnbytes.slice[arena] p offset [len]— a pointeroffsetbytes intop, recordinglen.reinterpret[arena] p nbytes [cleanup]— declare thatpaddressesnbytes. With an arena, the arena also callscleanupwithpon close — the place to hand a C library its own deallocator. The arena frees nothing here; the memory is C's.
An arena allocation records its size, and the arena forgets it on close. free forgets the size of the pointer it releases.
**Without an arena, segment, slice and reinterpret record for the life of the process.** That is the form to use for a pointer whose size you declare once at startup. It is the wrong form in a loop, for two reasons:
- The record is never reclaimed, so it grows without bound.
- The record is keyed by address, and an allocator hands the same address out again. Once the memory behind a declared address is released, the record outlives it, and a later allocation that lands there inherits a size it never had — which
copyandclonewould then use as a byte count.
Pass an arena and both problems go away, because the record dies with the group:
;; walking an array of structs — the sizes die with the arena
(with-open [a (ffi/confined-arena)]
(dotimes [i n]
(let [p (ffi/slice a arr (* i (ffi/sizeof point)) point)]
(handle (ffi/read p point)))))
reinterpret is also how a pointer from C gets a size in the first place. Give it the actual size: nothing can check it, Jolt does not bounds-check a read, and a size larger than the allocation is a read off the end.
Scoped allocation
Every alloc needs a matching free on every path out, including the one an exception takes. These macros bind a pointer for the body and release it exactly once when the body ends, however it ends.
with-alloc[ptr byte-count] & body— allocatebyte-countbytes.with-out[ptr scalar-type] & body— allocate one scalar, for an out-parameter.with-layout[ptr layout] & body— allocate one instance of a layout.with-c-string[ptr value] & body— a NUL-terminated UTF-8 copy ofvalue.with-c-string-array[ptr count] values & body— an array ofcountC strings.valuesis evaluated once, and if a conversion fails partway the strings already built are freed before the error propagates.
Each returns the body's value. The pointers are valid only inside the body — do not let one escape, since it is freed on the way out.
(defn open [path]
(ffi/with-out [pp :pointer] ; freed on both paths
(let [rc (sqlite3-open path pp)]
(when-not (= rc SQLITE-OK)
(throw (ex-info (str "sqlite open failed: " path) {:rc rc})))
(ffi/read pp :pointer))))
(ffi/with-c-string [s "SELECT 1"]
(sqlite3-prepare db s -1 stmt ffi/null))
These own only what they allocate. A handle C hands you — a FILE*, a connection, anything with its own close — is still yours to release.
babashka.ffi compatibility
jolt.ffi is a name-for-name, semantics-for-semantics match of babashka.ffi wherever one substrate can match the other, so a shim in either direction is a namespace alias plus a short list of gaps.
Matching: the arena constructors and every arena-taking function, alloc / free, read / write (including the argument order — the offset is last), sizeof, alignof, place, copy, clone, size, address, segment, slice, reinterpret, pointer?, null, null?, ptr->string, string->ptr, read-array, write-array, find-symbol, load-library, load-system-library, cfn, defcfn, callback, the :& variadic marker, and the type keywords with :bool included.
Where the two differ, they differ because the substrate does:
- A Jolt pointer is a raw address, not a sized
MemorySegment. Soreadandwritedo not bounds-check, and a zero-size pointer is not something Jolt can refuse.sizeanswers what Jolt was told — by an arena allocation,segmentorreinterpret— and0otherwise, which is enough forcopyandcloneto work without a count on memory Jolt handed out.pointer?is true for any non-negative integer, because that is all a pointer is here. cfnandcallbackare macros. Chez'sforeign-procedureneeds its types at compile time, which is what makes an emitted binding a real typed call and not an interpreter. So argtypes must be a literal vector, the target must be a literal C symbol name rather than a function pointer, and the library-scoped 4-argumentcfn(anddefcfn's:library) raises with an explanation instead of quietly searching every loaded library — Jolt already resolves a declared:jolt/native's symbols through its own handle, which is the guarantee that argument buys in babashka.ffi.- A layout is compiled by the
layoutmacro, andread/writetake that compiled value, where babashka.ffi takes the literal descriptor at each call. Chez builds the ABI layout with anftype, at compile time. - No
:unionin a layout, and nobyte-buffer— there is nojava.niohere.read-bytes,read-arrayandread-into!are the block moves. - No bare
:&. babashka.ffi's[:string :int :&]infers each call's tail from the values it is given; a compiledforeign-procedurehas its types fixed before any call, so the tail belongs to the binding — bind one signature per tail shape. The bare marker raises saying so.
Jolt adds, with no babashka.ffi equivalent: the arena-less (alloc n), the with-* scoped helpers, arena? / arena-open? / close-arena / drain-auto-arenas!, layout-size / layout-alignment / field-offset / read-field / write-field, foreign-fn / foreign-callable / free-callable / export!, :varargs (a second spelling of :&), :blocking, :capture-native-error, errno, loaded?, defining-libraries, read-bytes / write-bytes / read-into!, and the exact-width type aliases.