2026-02-08 15:49:40 -10:00
2026-02-08 11:20:43 -10:00
2026-02-08 11:20:43 -10:00
2026-02-08 11:20:43 -10:00
2026-02-08 11:20:43 -10:00
2026-02-08 11:20:43 -10:00
2026-02-08 11:20:43 -10:00
2026-02-08 11:20:43 -10:00
2026-02-08 15:49:40 -10:00

Kotlin DataFrame ↔ Clojure Data Ecosystem Research

Kotlin DataFrame (org.jetbrains.kotlinx.dataframe) is a JetBrains library for typesafe, columnar, in-memory data processing on the JVM. It pairs with Kandy for visualization.

The core question: Is this just a seq of maps with extra steps? And if so, can we bridge the two ecosystems on the JVM?

Key Findings

Kotlin DataFrame is NOT a seq of maps internally

  • It's column-oriented: a list of named typed columns (primitive arrays, string tables)
  • But it exposes row iteration as DataRow (essentially Map<String, Any?>)
  • It freely converts to/from List<DataClass> and Map<String, List<*>>
  • So conceptually yes, it represents the same data as a seq of maps, but stored columnar

Clojure equivalents exist

Concept KT DataFrame Tablecloth Plain Kotlin (List<Map>) Plain Clojure (seq of maps)
Tabular data DataFrame<T> tc/dataset List<Map<String, Any?>> [{:a 1} {:b 2}]
Create dataFrameOf("a" to listOf(1,2)) (tc/dataset {:a [1 2]}) listOf(mapOf("a" to 1), mapOf("a" to 2)) [{:a 1} {:a 2}]
Filter .filter { col > 10 } (tc/select-rows ds pred) .filter { it["col"] as Int > 10 } (filter #(> (:col %) 10) data)
Group + Aggregate .groupBy{}.aggregate{} (-> (tc/group-by) (tc/aggregate)) .groupingBy { it["k"] }.fold(0.0) { acc, r -> acc + r["x"] as Double } (->> data (group-by :k) (map (fn [[k vs]] {:k k :avg (/ (reduce + (map :x vs)) (count vs))})))
Computed column .add("y") { x * 2 } (tc/add-column ds :y fn) .map { it + ("y" to (it["x"] as Int) * 2) } (map #(assoc % :y (* (:x %) 2)) data)
Sort .sortBy { col } (tc/order-by ds :col) .sortedBy { it["col"] as Comparable<*> } (sort-by :col data)
Join .join(other) { col match right.col } (tc/left-join ds other :col) manual associateBy + map (clojure.set/join a b) or merge
Schema @DataSchema / compiler plugin malli schema data class spec / none
Schema inference @ImportDataSchema malli.provider/provide
Plotting Kandy Tableplot, Hanami, Oz
Notebook Kotlin Notebook Clay, Clerk

Schema inference from example data (Clojure)

Malli can infer schemas from example JSON/EDN:

(require '[malli.provider :as mp])
(mp/provide [{:name "Alice" :age 30 :tags ["admin"]}
             {:name "Bob"   :age 25}])
;; => [:map
;;     [:name string?]
;;     [:age number?]
;;     [:tags {:optional true} [:vector string?]]]
  • Detects optional keys automatically
  • Handles nested maps, vectors, sets
  • Can decode UUIDs, dates with custom decoders
  • Can export to JSON Schema via malli.json-schema/transform

Deep Research Findings (from source code analysis)

A. Kotlin DataFrame JVM Interop Surface

Core Architecture

DataFrame<T> (interface) -- container of columns
  └── DataFrameImpl<T> (internal) -- stores List<AnyCol> + nrow

DataColumn<T> (interface) -- a single column
  ├── ValueColumn<T> -- leaf values (backed by List<T>)
  ├── ColumnGroup<T> -- nested DataFrame (struct column)
  └── FrameColumn<T> -- column of DataFrames

DataRow<T> (interface) -- a single row view
  └── DataRowImpl<T> (internal) -- index + DataFrame reference

What's callable from Clojure (non-inline, non-reified)

Operation Java-callable? How to call from Clojure
Map<String, Iterable>.toDataFrame() YES (ToDataFrameKt/toDataFrame java-map)
Iterable<Map<String,Any?>>.toDataFrame() YES (ToDataFrameKt/toDataFrameMapStringAnyNullable seq-of-maps)
DataFrame.toMap() YES (TypeConversionsKt/toMap df)Map<String, List<Any?>>
DataRow.toMap() YES (TypeConversionsKt/toMap row)Map<String, Any?>
DataColumn.createByInference(name, values) YES (DataColumn/createByInference "col" java-list)
dataFrameOf(columns) YES (ConstructorsKt/dataFrameOf column-list)
DataFrame.columns() YES (.columns df)List<AnyCol>
DataFrame.get(name) YES (.get df "colname") → column
DataFrame.get(index) YES (.get df 0) → DataRow
DataFrame.iterator() YES (iterator-seq (.iterator df))
DataFrame.rowsCount() YES (.rowsCount df)
Iterable<T>.toDataFrame<reified T>() NO inline+reified, use Map variant instead
DataColumn.createValueColumn<reified> NO Use createByInference instead

Key insight: DataRow does NOT implement java.util.Map. It has .get(name) and .values() but you need .toMap() to get a real Map.

Column storage

  • ValueColumn: backed by List<T> (object list, not primitive arrays)
  • No primitive specialization — even ints are boxed in List<Int>
  • This means no zero-copy to dtype-next primitive buffers

B. tech.ml.dataset (TMD) / Tablecloth Interop Surface

TMD Column internals

(deftype Column
  [^RoaringBitmap missing    ;; missing value bitmap
   data                       ;; underlying data (dtype-next buffer, Java array, NIO buffer)
   ^IPersistentMap metadata   ;; column metadata (name, datatype, etc.)
   ^Buffer buffer])           ;; cached buffer view for fast access
  • Columns are dtype-next buffers — can be Java arrays, NIO ByteBuffers, or native memory
  • Missing values tracked separately via RoaringBitmap (not sentinel values)
  • Implements PToArrayBuffer — can convert to raw Java arrays if no missing values

Dataset creation from Java collections

;; From a column-oriented map (best for interop):
(ds/->dataset {"name" ["Alice" "Bob"] "age" [30 25]})

;; From a seq of row maps:
(ds/->dataset [{:name "Alice" :age 30} {:name "Bob" :age 25}])

;; Tablecloth wraps the same:
(tc/dataset {"name" ["Alice" "Bob"] "age" [30 25]})

Both ds/->dataset and tc/dataset accept java.util.Map directly.

C. Apache Arrow as Interchange Format

Both libraries support Arrow:

Kotlin DataFrame — separate module dataframe-arrow, supports Feather (v1, v2), Arrow IPC (streaming + file), LZ4/ZSTD compression.

tech.ml.dataset — built-in tech.v3.libs.arrow, supports memory-mapped reading for near-zero-copy ({:open-type :mmap}).

Verdict: Arrow is the right choice for large datasets or process boundaries. For same-process bridging, direct JVM interop via Map is simpler.

D. Malli Schema Inference

Aspect Malli Provider Kotlin @ImportDataSchema
When Runtime Compile-time
Input Any Clojure data JSON file on disk
Output Schema as data (EDN) Generated typed accessor code
Type safety Dynamic validation Static type checking
IDE support Limited Full autocomplete
Flexibility Handles unknown/evolving schemas Schema fixed at compile time
Best for Exploration, dynamic data Production, stable APIs

Bridge Design

The bridge is ~20 LOC. Both sides are columnar, so Map<String, List> is the natural interchange type.

Direct JVM interop (simplest, best for small-medium data)

(ns df-bridge.core
  (:import [org.jetbrains.kotlinx.dataframe.api ToDataFrameKt TypeConversionsKt]
           [org.jetbrains.kotlinx.dataframe DataColumn]))

;; KT DataFrame -> Clojure (column-oriented, fast)
(defn kt->map [kt-df]
  (into {} (TypeConversionsKt/toMap kt-df)))

;; KT DataFrame -> TMD dataset
(defn kt->dataset [kt-df]
  (-> (TypeConversionsKt/toMap kt-df)
      (ds/->dataset)))

;; Clojure -> KT DataFrame (column-oriented, fast)
(defn map->kt [col-map]
  (ToDataFrameKt/toDataFrame col-map))

;; TMD dataset -> KT DataFrame
(defn dataset->kt [ds]
  (let [col-map (into {} (map (fn [col]
                                 [(name (ds-col/column-name col))
                                  (vec col)])
                               (ds/columns ds)))]
    (ToDataFrameKt/toDataFrame col-map)))

Nested Data (ColumnGroups)

;; Create KT DataFrame with ColumnGroup from Clojure:
(bridge/make-kt-with-groups
  [["name" ["Alice" "Bob"]]
   ["address" {"city" ["NYC" "LA"]
               "zip"  [10001 90001]}]])

;; Convert back to row maps:
(bridge/kt->rows kt-df)
;; => [{:name "Alice", :address {:city "NYC", :zip 10001}}
;;     {:name "Bob",   :address {:city "LA",  :zip 90001}}]

Benchmark Results (3-column dataset: string, int, double)

Rows Map→KT KT→Map KT→TMD TC→KT Full RT
1K 0.3ms 0.005ms 0.3ms 0.2ms 0.4ms
100K 3.3ms 0.003ms 5.7ms 5.5ms 12.2ms
1M 33ms 0.004ms 72ms 60ms 134ms

Arrow vs Direct Map Comparison (4-column dataset)

Rows Direct Map KT→TMD Arrow file KT→TMD Arrow byte[] KT→TMD
10K 1.5ms 2.0ms 1.4ms
100K 11.6ms 9.1ms 7.1ms
1M 112ms 118ms 92ms

Key observations:

  • KT→Map is essentially free (~4µs) — toMap() just wraps existing column lists
  • Full roundtrip at 1M rows: 134ms — fine for interactive use
  • Arrow is NOT faster than direct Map for same-process bridging at any tested size
  • Verdict: Direct Map bridge wins for same-process. Arrow only for cross-process.

Visualization Stack Comparison

Clay + Tableplot (Clojure) vs Kotlin Notebook + Kandy

Operation Kotlin DataFrame + Kandy Tablecloth + Tableplot Plain Kotlin (stdlib) Plain Clojure (core)
Create data dataFrameOf("col" to list) (tc/dataset {"col" list}) listOf(mapOf("col" to v, ...)) [{:col v ...}]
Group + Aggregate df.groupBy { col }.aggregate { mean { x } into "y" } (-> ds (tc/group-by :col) (tc/aggregate {:y #(dfn/mean (% :x))})) data.groupingBy { it["col"] }.fold(0.0) { acc, r -> acc + (r["x"] as Double) } (->> data (group-by :col) (map (fn [[k vs]] {:col k :avg (/ (reduce + (map :x vs)) (count vs))})))
Filter df.filter { price > 100 } (tc/select-rows ds #(> (:price %) 100)) data.filter { it["price"] as Int > 100 } (filter #(> (:price %) 100) data)
Add column df.add("revenue") { price * quantity } (tc/map-columns ds "revenue" ["price" "quantity"] *) data.map { it + ("revenue" to (it["price"] as Int) * (it["qty"] as Int)) } (map #(assoc % :revenue (* (:price %) (:qty %))) data)
Sort df.sortBy { price } (tc/order-by ds :price) data.sortedBy { it["price"] as Int } (sort-by :price data)
Join df.join(other) { col match right.col } (tc/left-join ds other :col) val idx = other.associateBy { it["col"] }; data.map { it + (idx[it["col"]] ?: emptyMap()) } (clojure.set/join set-a set-b)
Bar chart df.plot { bars { x(col); y(col) } } (-> ds (plotly/base {:=x :col :=y :col}) (plotly/layer-bar {}))
Scatter df.plot { points { x(a); y(b); color(c) } } (-> ds (plotly/base {:=x :a :=y :b}) (plotly/layer-point {:=color :c}))
Histogram df.plot { histogram(x = col) } (-> ds (plotly/base {:=x :col}) (plotly/layer-histogram {}))
Notebook Kotlin Notebook (IntelliJ plugin, .ipynb) Clay (editor-agnostic, renders .clj → HTML)

Key differences:

  1. Type safety: Kotlin has compile-time column access (df.price), Clojure has runtime schema inference. Plain Kotlin collections need casts everywhere (it["x"] as Int). Plain Clojure needs neither — keywords are functions, values are dynamically typed.
  2. Filter/map/sort are ~identical: For row-level operations, plain collections and DataFrame APIs converge. Clojure's filter/map/sort-by + keywords-as-functions is nearly as concise as tablecloth.
  3. Group+aggregate is close in Clojure, improved in Kotlin via groupingBy: Clojure has group-by in core, and aggregation is just map + reduce over the groups. Kotlin has groupingBy which avoids materializing intermediate lists — .groupingBy { }.fold(init) { acc, elem -> } aggregates in a single pass. Both are reasonable without a DataFrame. Casts still plague the Kotlin version.
  4. Joins are built into Clojure: clojure.set/join performs natural inner joins on sets of maps, auto-detecting shared keys (via the first element of each set). Also supports explicit key mapping via 3-arity (join a b {:left-key :right-key}). Combined with merge, set/union, set/difference, set/intersection, set/select (filter), set/project (column select), and set/rename, Clojure has relational algebra in the stdlib. Caveats: inner join only (no left/right/outer), inputs must be sets (not vectors — silent wrong results otherwise), shared-key detection only inspects the first element of each set. Plain Kotlin has nothing comparable.
  5. Nil punning handles most missing-value cases: (:x row) on a row without :x returns nil. nil is falsy, (seq nil)nil, (conj nil x) works, (assoc nil :a 1){:a 1}, (count nil)0. Missing values flow through collection operations naturally. Caveat: nil breaks arithmetic — (+ 1 nil) and (> nil 5) throw NPE. Clojure provides fnil (default-substitution wrapper) and some-> (nil-short-circuiting thread) for these edges. In practice you (remove nil? ...) or (keep ...) before numeric reduction. TMD's RoaringBitmap approach handles this at the column level without per-row nil logic.
  6. Reducers and transducers close the performance gap: Transducers ((into [] (comp (filter pred) (map f)) data)) fuse pipeline stages into a single pass — no intermediate lazy seqs between steps. r/fold parallelizes via ForkJoinPool (default partition: 512 elements), splitting work across cores. Caveat: r/fold only parallelizes vectors and PersistentHashMaps; lists, lazy seqs, and sets silently fall back to sequential reduce. Parallel group-by works via (r/fold (r/monoid #(merge-with + %1 %2) (constantly {})) rf data-vec) but requires an associative combining function — not all aggregations (median, percentile) trivially parallelize. Columnar storage still wins for numeric-heavy workloads: TMD's dfn/mean operates on a contiguous typed double[] buffer with no boxing, which transducers over maps of boxed values can't match.
  7. Plotting: Both stacks are interactive. Kandy (Lets-Plot) supports tooltips by default and zoom/pan via ggtb() since Lets-Plot 4.5.0; renders via Swing, HTML, SVG, or PNG. Tableplot supports Plotly.js (interactive zoom/pan/hover out of the box) and Vega-Lite backends. No stdlib plotting in either language.
  8. REPL experience: Plain Clojure data is already inspectable at the REPL — DataFrames add pretty-printing but plain maps are arguably easier to inspect (no special printer needed).

Verdict: Plain Clojure seq-of-maps is competitive with DataFrame APIs for most operations. filter, map, sort-by, group-by, clojure.set/join, transducers, and r/fold are all in the stdlib. Keywords-as-functions and nil punning cover most data-wrangling patterns naturally — with known edges around arithmetic on nil and set/join requiring actual sets (not vectors). DataFrames earn their keep on: (a) contiguous typed column buffers for numeric-heavy workloads (no boxing, primitive reduction), (b) built-in statistical functions (dfn/mean, dfn/variance, etc.), (c) left/right/outer joins (tablecloth), and (d) ecosystem integration (plotting, notebooks, Arrow I/O). For small-to-medium data with simple transformations, plain Clojure maps may genuinely be all you need.


Project Structure

bridge/          — Working Clojure bridge project (deps.edn, benchmarks, notebooks)
dataframe/       — Kotlin DataFrame source (reference)
tech.ml.dataset/ — TMD source (reference)
tablecloth/      — Tablecloth source (reference)
malli/           — Malli source (reference)

Dependencies (bridge project)

{org.jetbrains.kotlinx/dataframe-core {:mvn/version "1.0.0-Beta4"}
 org.jetbrains.kotlin/kotlin-reflect   {:mvn/version "2.1.10"}
 scicloj/tablecloth                    {:mvn/version "7.062"}
 metosin/malli                         {:mvn/version "0.17.0"}
 org.scicloj/tableplot                 {:mvn/version "1-beta14"}
 org.scicloj/clay                      {:mvn/version "2-beta56"}}
Description
No description provided
Readme 102 MiB
Languages
Clojure 69.2%
Jupyter Notebook 30.8%