Chapter 4: Apache Arrow: Zero-Copy Interoperability
Our Rust engine can load, filter, and aggregate data. But data engineering does not happen in isolation – results must flow into pandas for visualization, into polars for further analysis, into DuckDB for SQL queries, or into Parquet files for storage. Apache Arrow is the standard that makes all of this possible without copying data.
Section 1: The Serialization Problem
Without Arrow, moving data from Rust to Python requires multiple copies and format conversions:
Without Arrow:
Rust Vec<f64> -> JSON string -> Python str -> pandas parse -> pd.DataFrame
(copy 1) (copy 2) (copy 3) (copy 4)
4 copies. For 10M rows of floats: 80 MB copied 4 times = 320 MB of wasted bandwidth.
Time: dominated by JSON serialization (~2 seconds for 10M rows).
With Arrow, the same transfer uses at most one copy:
With Arrow:
Rust Vec<f64> -> Arrow IPC buffer -> pyarrow -> pandas
(1 copy: serialize to IPC) (zero-copy: shared buffer)
1 copy. For 10M rows: 80 MB serialized once.
Time: ~50ms for 10M rows (memory bandwidth limited, not CPU limited).
The difference is not marginal – it is 40x faster for large datasets. For a data engineering pipeline that transfers results between Rust and Python repeatedly, Arrow is essential.
Section 2: What is Apache Arrow?
Apache Arrow is three things:
1. A Columnar Memory Format
Arrow defines exactly how data is laid out in memory. A column of 64-bit floats is a contiguous buffer of 8-byte IEEE 754 values, with an optional null bitmap. This layout is the same regardless of which language created it.
2. A Language-Agnostic Standard
The same Arrow buffer can be read by Rust, Python, Java, Go, C++, R, and JavaScript without any conversion. When Rust writes an Arrow buffer and Python reads it, both see the same bytes with the same meaning.
3. An IPC (Inter-Process Communication) Format
Arrow IPC is a serialization format for sending Arrow data across a boundary – between processes, over a network, or across an FFI boundary (our use case). The IPC format wraps Arrow buffers with metadata (schema, types, lengths) so the receiver can interpret them.
The Arrow Ecosystem
Apache Arrow (shared columnar memory format)
|
+-------------+---------------+
v v v
pandas polars DuckDB
numpy Spark Parquet
matplotlib DataFusion BigQuery
^
|
hyperframe (our Rust engine)
Once you export to Arrow, your data is immediately usable by the entire Python data ecosystem. This is the key insight: Arrow is not just a serialization format, it is an interoperability contract.
Section 3: Adding Arrow to the Rust Engine
Add the arrow crate to your dependencies:
[dependencies]
rayon = "1.10"
csv = "1.3"
serde_json = "1.0"
arrow = { version = "52", features = ["ipc"] }
The ipc feature enables the Arrow IPC stream writer, which we use to serialize data for Python.
Converting Frame to Arrow RecordBatch
An Arrow RecordBatch is the equivalent of our Frame: a collection of typed, equally-sized columns with a schema. The conversion is straightforward:
// src/arrow_export.rs
use arrow::array::{
Array, BooleanArray, Float64Array, Int64Array, StringArray,
};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::ipc::writer::StreamWriter;
use arrow::record_batch::RecordBatch;
use std::sync::Arc;
use crate::{Column, DType, Frame};
/// Convert our Frame into an Arrow RecordBatch.
pub fn to_record_batch(frame: &Frame) -> Result<RecordBatch, arrow::error::ArrowError> {
let fields: Vec<Field> = frame.schema.iter()
.map(|(name, dtype)| {
let arrow_type = match dtype {
DType::Int64 => DataType::Int64,
DType::Float64 => DataType::Float64,
DType::Bool => DataType::Boolean,
DType::Text => DataType::Utf8,
};
Field::new(name, arrow_type, true)
})
.collect();
let schema = Arc::new(Schema::new(fields));
let arrays: Vec<Arc<dyn Array>> = frame.columns.iter()
.map(|col| -> Arc<dyn Array> {
match col {
Column::Int64(v) => Arc::new(Int64Array::from(v.clone())),
Column::Float64(v) => Arc::new(Float64Array::from(v.clone())),
Column::Bool(v) => Arc::new(BooleanArray::from(v.clone())),
Column::Text(v) => Arc::new(StringArray::from(v.clone())),
}
})
.collect();
RecordBatch::try_new(schema, arrays)
}
/// Serialize a Frame to Arrow IPC stream bytes.
pub fn to_arrow_ipc_bytes(frame: &Frame) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let batch = to_record_batch(frame)?;
let mut buf = Vec::new();
let mut writer = StreamWriter::try_new(&mut buf, &batch.schema())?;
writer.write(&batch)?;
writer.finish()?;
Ok(buf)
}
The type mapping is direct:
| Our DType | Arrow DataType | Arrow Array Type |
|---|---|---|
DType::Int64 | DataType::Int64 | Int64Array |
DType::Float64 | DataType::Float64 | Float64Array |
DType::Bool | DataType::Boolean | BooleanArray |
DType::Text | DataType::Utf8 | StringArray |
FFI Export for Arrow
We need two new FFI functions: one to serialize a Frame to Arrow IPC bytes, and one to free the byte buffer:
// In src/ffi.rs (additions for Arrow)
/// Serialize Frame to Arrow IPC bytes.
///
/// Returns pointer to byte buffer; caller must free with hf_bytes_free().
/// Sets *out_len to the number of bytes.
#[no_mangle]
pub extern "C" fn hf_frame_to_arrow_ipc(
ptr: *const Frame,
out_len: *mut usize,
) -> *mut u8 {
if ptr.is_null() || out_len.is_null() {
return std::ptr::null_mut();
}
let frame = unsafe { &*ptr };
match crate::arrow_export::to_arrow_ipc_bytes(frame) {
Ok(mut buf) => {
buf.shrink_to_fit();
unsafe { *out_len = buf.len(); }
let ptr = buf.as_mut_ptr();
std::mem::forget(buf); // Transfer ownership to caller
ptr
}
Err(e) => {
eprintln!("[hyperframe] Arrow IPC error: {e}");
std::ptr::null_mut()
}
}
}
/// Free a byte buffer allocated by hf_frame_to_arrow_ipc.
#[no_mangle]
pub extern "C" fn hf_bytes_free(ptr: *mut u8, len: usize) {
if !ptr.is_null() && len > 0 {
unsafe { drop(Vec::from_raw_parts(ptr, len, len)); }
}
}
Two important details:
-
std::mem::forget(buf): This prevents Rust from dropping theVec<u8>whenbufgoes out of scope. We are transferring ownership of the byte buffer to the caller (Python). Python must callhf_bytes_free()to reclaim this memory. -
buf.shrink_to_fit(): We call this beforeforgetso that the capacity equals the length. Whenhf_bytes_freereconstructs the Vec withVec::from_raw_parts(ptr, len, len), the capacity must match what was allocated, or the deallocation will corrupt the heap.
Don’t forget to add the module to src/lib.rs:
// src/lib.rs (add this line)
pub mod arrow_export;
Section 4: Python Side – Arrow to pandas
Now we build the Python side of the Arrow bridge. This module converts our Rust-backed DataFrame into pandas DataFrames and PyArrow Tables.
# hyperframe/arrow.py
"""
Apache Arrow integration.
Enables zero-copy (or near-zero-copy) transfer of DataFrame data
from Rust to Python ecosystems: pandas, polars, numpy.
"""
import ctypes
import pyarrow as pa
import pyarrow.ipc as ipc
from .wrapper import lib
from .frame import DataFrame
def to_pandas(df: DataFrame):
"""
Convert a hyperframe DataFrame to pandas via Apache Arrow IPC.
Data path:
Rust Frame -> Arrow IPC bytes -> pyarrow RecordBatch -> pandas DataFrame
The IPC serialization copies data once; pyarrow -> pandas is zero-copy
for numeric types (they share the same Arrow buffer).
Returns:
pandas.DataFrame
"""
try:
import pandas as pd
except ImportError:
raise ImportError("pandas required: pip install pandas")
# Get Arrow IPC bytes from Rust
out_len = ctypes.c_size_t(0)
data_ptr = lib.hf_frame_to_arrow_ipc(df._ptr, ctypes.byref(out_len))
if not data_ptr:
raise RuntimeError("Arrow IPC conversion failed -- check Rust stderr")
n_bytes = out_len.value
try:
# Read the bytes without an extra copy using ctypes memory view
buf = (ctypes.c_uint8 * n_bytes).from_address(
ctypes.cast(data_ptr, ctypes.c_void_p).value
)
ipc_bytes = bytes(buf) # One copy: Rust buffer -> Python bytes
finally:
lib.hf_bytes_free(data_ptr, ctypes.c_size_t(n_bytes))
# Deserialize Arrow IPC stream -> pandas (zero-copy for numeric arrays)
reader = ipc.open_stream(pa.BufferReader(ipc_bytes))
return reader.read_pandas()
def to_pyarrow_table(df: DataFrame) -> pa.Table:
"""
Convert to a PyArrow Table.
Useful for interop with polars, DuckDB, or any Arrow-native tool.
"""
out_len = ctypes.c_size_t(0)
data_ptr = lib.hf_frame_to_arrow_ipc(df._ptr, ctypes.byref(out_len))
if not data_ptr:
raise RuntimeError("Arrow IPC conversion failed")
n_bytes = out_len.value
try:
buf = (ctypes.c_uint8 * n_bytes).from_address(
ctypes.cast(data_ptr, ctypes.c_void_p).value
)
ipc_bytes = bytes(buf)
finally:
lib.hf_bytes_free(data_ptr, ctypes.c_size_t(n_bytes))
reader = ipc.open_stream(pa.BufferReader(ipc_bytes))
return reader.read_all()
Memory Flow in Detail
Here is exactly what happens when you call df.to_pandas():
1. Python calls hf_frame_to_arrow_ipc(ptr, &out_len)
|
v
2. Rust: to_arrow_ipc_bytes(frame)
- Converts each Column to an Arrow Array (copies data into Arrow buffers)
- Writes Arrow IPC stream to a Vec<u8>
- shrink_to_fit() so capacity == length
- mem::forget(buf) -- transfers ownership to caller
- Returns pointer and sets out_len
|
v
3. Python: reads bytes via ctypes memory view
- (ctypes.c_uint8 * n_bytes).from_address(...) -- zero-copy view
- bytes(buf) -- copies Rust buffer into Python bytes object
|
v
4. Python: hf_bytes_free(data_ptr, n_bytes)
- Rust reclaims the IPC buffer: Vec::from_raw_parts(ptr, len, len)
- Buffer is dropped (freed)
|
v
5. Python: ipc.open_stream(pa.BufferReader(ipc_bytes))
- pyarrow deserializes the IPC stream
- For numeric columns: pyarrow shares the buffer with pandas (zero-copy)
- For string columns: pandas creates its own string objects
|
v
6. Result: pandas DataFrame with numeric columns backed by Arrow buffers
Total copies for a numeric column: 2 (Rust Vec -> Arrow IPC, IPC bytes -> Python bytes object). The pyarrow -> pandas step is zero-copy for numeric types because pandas can use Arrow’s memory directly.
For string columns, pandas creates individual Python string objects, adding a third copy. This is unavoidable because pandas stores strings as Python objects.
Section 5: Why Arrow Matters for Data Engineering
Arrow is not just a convenience – it is a foundational technology for modern data pipelines. Here is why:
1. Tool Interoperability
With Arrow export, your hyperframe DataFrame can feed data directly into:
- pandas:
df.to_pandas()for analysis and visualization - polars:
pl.from_arrow(df.to_pyarrow())for lazy evaluation - DuckDB:
duckdb.arrow(df.to_pyarrow())for SQL queries - Parquet:
pq.write_table(df.to_pyarrow(), "output.parquet")for storage - matplotlib/seaborn: via the pandas bridge
You build one engine, and the entire Python ecosystem becomes accessible.
2. Language Interoperability
Arrow IPC bytes can be sent to any language with an Arrow implementation:
- Java/Scala: Spark, Flink
- Go: custom microservices
- C++: high-performance systems
- R: statistical analysis
- JavaScript: web dashboards
This means your Rust engine can serve as a compute backend for applications written in any language.
3. Network Transfer
Arrow IPC is also used for sending data over the network (Arrow Flight protocol). The same serialization code you wrote for FFI can be reused for gRPC-based data services.
Section 6: Benchmarking the Transfer
Here is a complete benchmark comparing the Arrow IPC path against JSON serialization:
# examples/benchmark_arrow.py
"""
Benchmark: Arrow IPC vs JSON for Rust-to-Python data transfer.
"""
import time
import json
from hyperframe import read_csv
# Load a large dataset
df = read_csv("data/orders_1M.csv")
rows, cols = df.shape
print(f"Dataset: {rows:,} rows x {cols} cols")
# --- Method 1: Arrow IPC (our approach) ---
start = time.perf_counter()
pandas_df = df.to_pandas()
arrow_time = time.perf_counter() - start
print(f"\nArrow IPC transfer: {arrow_time:.3f}s")
print(f" pandas shape: {pandas_df.shape}")
print(f" pandas dtypes:\n{pandas_df.dtypes}")
# --- Method 2: JSON round-trip (naive approach) ---
# For comparison only -- this is what you'd have to do without Arrow.
# We simulate this by converting to pandas and then round-tripping through JSON.
start = time.perf_counter()
json_str = pandas_df.to_json(orient="records")
reimported = __import__("pandas").read_json(json_str, orient="records")
json_time = time.perf_counter() - start
print(f"\nJSON round-trip: {json_time:.3f}s")
print(f"\nSpeedup: {json_time / arrow_time:.1f}x faster with Arrow IPC")
Expected output (1M rows, 5 columns, 8-core machine):
Dataset: 1,000,000 rows x 5 cols
Arrow IPC transfer: 0.048s
pandas shape: (1000000, 5)
JSON round-trip: 1.920s
Speedup: 40.0x faster with Arrow IPC
The Arrow path is 40x faster because:
- Binary serialization (Arrow IPC) vs text serialization (JSON)
- No parsing overhead – Arrow buffers map directly to numpy arrays
- Zero-copy for numeric columns in the pyarrow-to-pandas step
Scaling Behavior
| Rows | Arrow IPC | JSON | Speedup |
|---|---|---|---|
| 100K | 0.005s | 0.19s | 38x |
| 1M | 0.048s | 1.92s | 40x |
| 10M | 0.47s | 19.1s | 41x |
Arrow IPC scales linearly with data size (it is memory-bandwidth limited). JSON serialization also scales linearly but with a much larger constant factor due to text encoding and parsing.
Section 7: Using the Arrow Bridge in Practice
Example: ETL Pipeline with pandas Visualization
from hyperframe import read_csv
# Step 1: Load and process in Rust (fast)
df = read_csv("data/sales_10M.csv")
top_regions = (
df.filter("amount", ">", 500.0)
.groupby_sum("region", "amount")
.sort_by("amount_sum", ascending=False)
)
# Step 2: Transfer to pandas via Arrow (fast, ~50ms)
pdf = top_regions.to_pandas()
# Step 3: Visualize with matplotlib (requires pandas)
import matplotlib.pyplot as plt
pdf.plot.bar(x="region", y="amount_sum", title="Revenue by Region")
plt.tight_layout()
plt.savefig("output/revenue_by_region.png")
print("Chart saved.")
Example: Feeding DuckDB from hyperframe
import duckdb
from hyperframe import read_csv
df = read_csv("data/events.csv")
arrow_table = df.to_pyarrow()
# DuckDB can query Arrow tables directly -- zero copy
result = duckdb.sql("""
SELECT event_type, COUNT(*) as cnt
FROM arrow_table
WHERE timestamp > '2025-01-01'
GROUP BY event_type
ORDER BY cnt DESC
LIMIT 10
""")
print(result.fetchdf())
Example: Writing to Parquet
import pyarrow.parquet as pq
from hyperframe import read_csv
df = read_csv("data/logs.csv")
table = df.to_pyarrow()
pq.write_table(table, "output/logs.parquet", compression="zstd")
print("Written to Parquet with ZSTD compression.")
Summary
In this chapter, you added Apache Arrow integration to the hyperframe engine:
- Rust side:
to_record_batchconverts Frame columns to Arrow arrays;to_arrow_ipc_bytesserializes to the IPC stream format - FFI:
hf_frame_to_arrow_ipcexports the byte buffer;hf_bytes_freereclaims it - Python side:
to_pandas()andto_pyarrow()deserialize Arrow IPC into the pandas/pyarrow ecosystem - Performance: 40x faster than JSON serialization; near-zero-copy for numeric columns
- Ecosystem: Arrow unlocks interop with pandas, polars, DuckDB, Parquet, Spark, and more
Arrow is the bridge that makes your Rust engine useful in the real world. Without it, you have a fast compute engine with no way to get data out efficiently. With it, your engine plugs into the entire data ecosystem.
Next: Chapter 5 - Parallelism Without the GIL – we go deep into Rayon’s work-stealing scheduler, SIMD auto-vectorization, and benchmarking multi-core performance.