Apache Arrow vs JDBC in Mondrian: Honest Benchmarks
New! Listen to Concept to Cloud - Real stories from the trenches of software engineering
What Apache Arrow Actually Did To Mondrian, And What It Didn't
Tips

What Apache Arrow Actually Did To Mondrian, And What It Didn't

TB
Tom Barber
May 18, 2026
0 min read

After replacing Mondrian's SQL emitter with Calcite, the next obvious target was the data path itself: an Apache Arrow segment cache and ADBC connections to replace JDBC. The first benchmarks said Arrow was 2.6x slower. They were wrong, but not for the reasons I expected. Here's the honest report on where columnar wins, where it ties, and where it actually loses.

What Apache Arrow Actually Did To Mondrian, And What It Didn’t

A few weeks ago I wrote up what happened when we replaced Mondrian’s hand-rolled SQL emitter with Apache Calcite. That post was about the planner layer, the part that decides what SQL to send. This post is about the layer below: how data actually moves between the database and Mondrian’s in-memory cache, and whether Apache Arrow and ADBC can make that path faster. For more on where Mondrian sits in 2026 and why it’s still worth investing in, see the Mondrian OLAP state-of-the-engine writeup.

The short version: yes, sometimes. No, often. And the answer is more nuanced than either the Arrow marketing or the “just keep using JDBC” reflex would suggest.

Why Bother With Arrow At All

Mondrian’s hottest in-memory data structure is the segment cache, millions of double-precision numbers, indexed by every MDX query that runs. For twenty years it’s been a double[] array. Java arrays are about as fast as numbers in memory get on the JVM.

Apache Arrow is the modern alternative. Instead of a heap-allocated array, the same numbers live in an off-heap, columnar buffer with a defined cross-language wire format. The pitch is twofold:

  1. The same memory layout works inside the JVM, inside DuckDB, inside Python’s Pandas, and on the wire, no copies, no conversions.
  2. The wire format is column-oriented, which suits analytical workloads better than row-shaped JDBC.

That second point is where ADBC, Arrow Database Connectivity, comes in. It’s the spiritual successor to JDBC for analytics: the result set is Arrow batches, not rows.

The bet was: replace the array-based segment cache with an Arrow-based one, and swap JDBC for ADBC where the driver exists. Faster ingestion, less garbage collection, future-proof for engines like DuckDB that speak Arrow natively.

The first measurement said it was a bad idea.

The 2.6x Slowdown That Wasn’t

I built an Arrow-backed segment vector, wired it into the cache, and ran a hundred thousand cells through a tight summation loop. A million iterations in total. The result:

array  =  74.6 ms
arrow  = 194.5 ms
ratio  = 2.61x

Arrow was 2.6x slower. Per-cell reads through Arrow’s high-level API were taking nearly three times as long as reading from a plain double[]. The whole architectural idea hinged on Arrow being at least as fast as a heap array; if it wasn’t, the rest of the work didn’t matter.

I wrote it up, opened the PR with a “park this” recommendation, and was ready to move on. Then I got asked the question that flipped it: “is there anything we can look at to work out why it’s so much slower?”

It’s a small question. It cost me an afternoon. It reversed the architectural decision.

What Arrow Was Actually Doing

When you read a value from an Arrow Float8Vector, the columnar equivalent of double[], the library does more than just fetch a number. It checks whether nulls are enabled globally. It looks up a validity bitmap to confirm the value isn’t null. It pays the cost of a virtual method call to do that. Then, finally, it reads the actual number out of off-heap memory.

A double[] read is one CPU instruction. The Arrow read is a dozen.

Each of those steps exists for a good reason. Arrow’s contract is that get(i) is null-safe and throws helpfully if you ask for a null. That’s a perfectly reasonable API choice. It’s also slow when you’re reading the same vector ten million times in a hot loop, and you already know there are no nulls.

The fix, once I understood the shape of the problem, was structural rather than clever. We cache the underlying memory buffer once when we construct the vector, and read through it directly on the hot path. The high-level wrapper is still available for code that needs the safety contract, the cell-evaluator just doesn’t go through it.

Combined with a one-line JVM flag that disables the per-read null check globally (-Darrow.enable_null_check_for_get=false), the same benchmark looked like this:

array  =  74.4 ms
arrow  = 104.9 ms (was 194.5)
ratio  =   1.41x  (was 2.61x)

A 2.6x slowdown became a 1.4x slowdown. Not parity, interface method dispatch costs the rest of the gap, but well within the range where Arrow’s other wins (cross-process sharing, SIMD-friendly bulk operations, off-heap memory pressure relief) make the trade worth it.

The lesson, generalised: a slow benchmark of a high-level abstraction doesn’t mean the underlying technology is slow. It means the abstraction has a cost. The Arrow native layer was always fast, direct off-heap access was within 1% of double[] performance. The cost lived in the safety wrapper, not the wire format.

The Same Lesson, Twice In One Week

This is where the story gets slightly embarrassing.

A few days later I started looking at ADBC, using Arrow as the actual wire format between Mondrian and DuckDB, rather than just the in-memory storage. Built the spike, ran a benchmark, and:

jdbc  = 6.9 ms
arrow = 8.2 ms
ratio = 1.18x

Arrow was 18% slower than JDBC for a hundred-thousand-row scan. Wrote it up. Opened the PR. “Park here, in-process DuckDB doesn’t have a wire to optimise.”

Then the same question came back: “you sure there’s nothing we can tweak?”

I had, three days earlier, written down the exact tuning recipe that would fix this. I’d shipped the wrapper-bypass fix into the segment cache code. I just didn’t apply the same knowledge to the new piece of code. Knowledge that lives in commit messages doesn’t migrate to new code unless you actively port it.

With the same wrapper bypass, the same JVM flag, and one new knob, telling DuckDB to send a single large batch instead of twelve small ones, Arrow went from 18% slower to 22% faster than JDBC, even for an in-process database where there’s no network to optimise.

The naive Arrow-vs-JDBC comparison was the wrong comparison. Both libraries have tuning surfaces. The honest comparison is tuned-Arrow vs tuned-JDBC, and tuned-Arrow wins.

Where ADBC Actually Helps (And Where It Doesn’t)

DuckDB is the easy case. It already speaks Arrow natively, you ask for an Arrow batch, you get one off the wire. The tuning recipe applies cleanly. Win confirmed.

The harder question is what happens with Postgres, which is what most Mondrian deployments actually run against.

There’s a Java library called arrow-jdbc that converts a JDBC ResultSet to Arrow batches on the Java side. I tried it, applied the full recipe, and got results that were 7-11% slower than plain JDBC. The recipe didn’t transfer.

Why? Because arrow-jdbc is Arrow-shaped data over a JDBC-shaped wire. The wire is still row-by-row. The Java code reads each row from JDBC, then materialises it into an Arrow batch on the heap. You pay all of JDBC’s transport cost plus the conversion overhead. No wire benefit to amortise against.

This is the kind of result that’s easy to mis-report. “Arrow doesn’t help Postgres” sounds like a finding. It isn’t. The right framing is: arrow-jdbc is the wrong target. Arrow over a row-shaped wire is architecturally pointless.

The right target is the native ADBC driver for Postgres, libadbc-postgresql, which talks to Postgres in its binary protocol and produces Arrow batches without ever materialising rows in between. Apache hasn’t packaged it for easy Java consumption yet, but it builds from source in three minutes, and the result is striking:

jdbc default                       459 ms
arrow-jdbc (Java conversion)       408 ms   (0.89x, marginal)
native ADBC + recipe               309 ms   (0.67x, 33% faster)

So Postgres can win with Arrow. The arrow-jdbc adapter just isn’t how. The actual native ADBC path, on a million-row scan, is a third faster than JDBC.

The Corpus That Reframed Everything

A single benchmark on a single shape will lie to you. To get a real picture I ran seven different SQL shapes, raw scans, aggregations, joins, time filters, agg-table reads, through both JDBC and native ADBC against an 86-million-row Postgres instance, and looked at the distribution.

Query shapeJDBCADBCWinner
Raw scan (1M rows)284 ms222 msArrow +22%
Aggregation (group by store)7,207 ms7,179 mstied
Multi-dim aggregation8,056 ms8,067 mstied
Time-filtered aggregation8,228 ms8,306 mstied
Multi-measure aggregation10,213 ms10,220 mstied
Aggregate-table read (2,600 rows)4.5 ms15.2 msJDBC 3.4x faster

Three distinct regimes.

Arrow wins for transfer-bound workloads. Raw scans where the wire actually has to move a lot of data. About a 22% improvement, consistent with what the ADBC marketing claims.

Arrow ties on server-side aggregations. When Postgres does all the work and ships back a small result set, the wire is a rounding error against eight seconds of server compute. Neither path has an advantage. This is the majority of what Mondrian’s segment loader actually does.

Arrow loses on tiny result sets. The aggregate-table read returns a few thousand pre-aggregated rows in milliseconds. ADBC’s fixed setup cost, initialising allocators, crossing the JNI boundary, opening the IPC channel, dominates that timescale. JDBC, with its long-warmed pooled connection, wins by a factor of three.

The takeaway is uncomfortable for anyone hoping for a one-line “use Arrow” recommendation: the wire-format choice matters where the wire is the bottleneck, and Mondrian’s planner already works hard to make sure it isn’t. Most of what Mondrian asks a database to do is precisely the case where Arrow doesn’t help.

How This Pairs With The Calcite Story

The earlier post on swapping Mondrian’s SQL emitter for Calcite ended with a similar shape of result, Calcite produced cleaner SQL, Postgres planned it identically to the legacy SQL, and most queries ran at parity. The real Calcite win was structural: automatic aggregate-table rewriting, modern dialect coverage, the ability to add a new database with one line of mapping code.

Arrow and ADBC are the same kind of story, one layer down. Calcite tuned what Mondrian asks the database to do. Arrow and ADBC tune how the answer gets back. They optimise different layers and they compose cleanly, but they don’t compete, and neither one is a universal speedup.

For Mondrian specifically, the practical map looks like this:

WorkloadWhat helps
Segment-load aggregationsCalcite (better plans, agg-table rewriting), Arrow neutral
Drillthrough / raw row exportArrow / native ADBC (real wire wins)
Aggregate-table readsStay on JDBC, ADBC setup cost dominates
In-process engines (DuckDB)Arrow natively, with the tuning recipe
Cloud warehouses (Snowflake, BigQuery, Databricks)Native ADBC drivers, almost certainly a real win, untested at production scale today

What I’d Tell Anyone Looking At This Path

A few honest takeaways for anyone considering the same migration on their own analytical stack:

Don’t trust the first benchmark. Mine said 2.6x slower, then 1.18x slower, then both reversed under tuning. Naive Arrow versus naive JDBC is comparing two unconfigured libraries, both have tuning surfaces, and the gap moves a lot when you actually use them.

The Arrow win requires Arrow on the wire. Layering Arrow-shaped objects on top of row-shaped transport is just conversion overhead. If the producer doesn’t natively speak Arrow, you’re paying for the badge without the benefit.

Single-shape benchmarks lie about distribution. The same library can be 22% faster, tied, and 3x slower across queries that all sound similar in plain English. The honest answer is a per-shape table, not a single number.

Most of Mondrian’s work happens in the regime where the wire isn’t the bottleneck. That’s not a failure of Arrow, it’s a success of Mondrian’s planner, which has spent twenty years getting the database to do the heavy lifting close to the data. Arrow’s wire benefit lands cleanly on the workloads where Mondrian isn’t already optimised, primarily drillthrough and bulk export.

A one-line JVM flag can halve your library’s overhead. Knowing the per-library escape hatches matters. For Arrow on the JVM it’s the null-check toggle. For JDBC drivers it’s usually fetchSize or defaultRowFetchSize. Both worth knowing per deployment.

Where We Go From Here

The Arrow-backed segment cache is in the Mondrian codebase behind a feature flag, alongside the array-backed default. With the tuning recipe applied, it’s competitive on cell-read performance and unlocks the SIMD and cross-process possibilities that the original array implementation closed off.

The native ADBC path for Postgres is wired up and tested. It’s not yet a default, building libadbc-postgresql from source is fine for our infrastructure but not something most deployments will be willing to do until Apache ships better packaging. When they do, the integration is ready.

The cloud-warehouse story, Snowflake, BigQuery, Databricks, is the most exciting and the one I haven’t been able to validate at production scale yet. Each of those ships a native ADBC driver. The corpus benchmark suggests they’re exactly the workload shape where Arrow’s wire benefit should be largest. That’s a future post.

For now, the honest summary is: Arrow is real, the recipe matters, and the answer to “should I use it?” is “it depends what your bottleneck actually is.” Which is, I suspect, the honest answer to most performance questions.


If you’re running analytical workloads at scale and weighing up where to put effort, planner-layer work, wire-format work, or somewhere else entirely, I’m happy to compare notes. The deeper engineering write-up with the benchmark methodology, the full tuning recipe, and the bytecode-level diagnostic is on our technical Substack.

TB
Written by Tom Barber

Ex-NASA engineer and cloud architect with over a decade of experience building scalable systems for startups and enterprises.

Work with Tom →

Related Articles

Research

Ultimate Guide to Cloud Based Research

Explore how cloud computing transforms collaborative research by enabling seamless data sharing and real-time teamwork across distributed global teams.

Read More →
Tips

Entity Resolution in Postgres: Trigrams, Jaro-Winkler, and Vector Embeddings Compared

Deduping messy real-world data, companies, addresses, people, looks like a solved problem until you do it. Here's an honest comparison of the three techniques that actually matter inside Postgres, when each one wins, and where each one quietly breaks.

Read More →
Strategy

Rebuilding Saiku: Bringing a Commercial Open Source OLAP Tool Back With AI

Years ago I walked away from Saiku, the commercial open source OLAP tool I'd built and run for the better part of a decade. This year, with a few weeks off and an agentic coding agent at my disposal, I rebuilt it, new UI, modernised dependencies, and a new SQL engine underneath. Here's what happened, and why I'm releasing it again.

Read More →

Ready to Build Your Product?

Let's discuss how we can help you bring your vision to life with expert cloud solutions

Get Started