Chapter 3: Bridging Rust and Python with ctypes
In Chapter 2, we built a Rust engine with 12 exported FFI functions. In this chapter, we build the Python SDK that wraps those functions into a clean, Pythonic API. By the end, you will be able to write:
from hyperframe import DataFrame, read_csv
df = read_csv("sales.csv")
print(df.shape) # (1000000, 5)
print(df.sum("revenue")) # 4283917.50
high_value = df.filter("revenue", ">", 1000.0)
by_region = high_value.groupby_sum("region", "revenue")
print(by_region)
All computation runs in Rust. Python holds only opaque pointers.
Section 1: Why ctypes Over PyO3
There are two mainstream approaches for calling Rust from Python:
| Approach | Build-time deps | Distribution | Type safety | Boilerplate |
|---|---|---|---|---|
| ctypes | None (pure Python) | Precompiled binary | Manual | More |
| PyO3 | Python headers + Rust toolchain | maturin build | Automatic | Less |
ctypes is the right choice when you want to ship precompiled binaries with zero build-time dependencies. Users install the Python package and get a working library immediately – no Rust toolchain required. This is ideal for proprietary or commercial SDKs.
PyO3 is better for open-source libraries where users compile from source, or when you want automatic GIL management and native Python type conversion. The trade-off is a tighter coupling between the Rust and Python build processes.
This course uses ctypes because it teaches the fundamentals of FFI explicitly, and because the distribution model (precompiled binaries per platform) is common in production data engineering.
Section 2: The Wrapper Module
The wrapper module is the foundation of the SDK. It loads the platform-specific shared library and defines the type signatures for every FFI function.
# hyperframe/wrapper.py
"""
ctypes FFI layer -- loads the compiled Rust library and defines all function signatures.
Import this module to get access to `lib` (the loaded ctypes.CDLL).
"""
import ctypes
import sys
import platform
from pathlib import Path
def _find_library() -> Path:
"""
Locate the compiled Rust shared library relative to this file.
The binary must be placed in:
hyperframe/libs/Linux/libhyperframe_core.so (Linux)
hyperframe/libs/Mac/libhyperframe_core-arm64.dylib (macOS ARM)
hyperframe/libs/Mac/libhyperframe_core-x86_64.dylib (macOS Intel)
hyperframe/libs/Win/hyperframe_core.dll (Windows)
"""
base = Path(__file__).parent
if sys.platform == "linux":
lib_path = base / "libs" / "Linux" / "libhyperframe_core.so"
elif sys.platform == "darwin":
arch = platform.machine()
name = ("libhyperframe_core-arm64.dylib" if arch == "arm64"
else "libhyperframe_core-x86_64.dylib")
lib_path = base / "libs" / "Mac" / name
elif sys.platform == "win32":
lib_path = base / "libs" / "Win" / "hyperframe_core.dll"
else:
raise OSError(f"Unsupported platform: {sys.platform}")
if not lib_path.exists():
raise ImportError(
f"hyperframe-core binary not found at:\n {lib_path}\n\n"
"Build it first:\n"
" cd hyperframe-core/\n"
" cargo build --release\n"
" cp target/release/libhyperframe_core.so "
"../hyperframe_sdk/hyperframe/libs/Linux/"
)
return lib_path
lib = ctypes.CDLL(str(_find_library()))
# ---------------------------------------------------------------------------
# Type aliases
# ---------------------------------------------------------------------------
_ptr = ctypes.c_void_p # opaque Rust pointer
_str = ctypes.c_char_p # null-terminated C string
# ---------------------------------------------------------------------------
# Function signatures
# Defining these correctly is critical:
# - Wrong argtypes -> silent data corruption
# - Wrong restype -> crashes or wrong values
# ---------------------------------------------------------------------------
# Memory management
lib.hf_frame_free.argtypes = [_ptr]
lib.hf_frame_free.restype = None
# Data loading
lib.hf_frame_from_csv.argtypes = [_str]
lib.hf_frame_from_csv.restype = _ptr
lib.hf_frame_from_records.argtypes = [ctypes.c_char_p, ctypes.c_size_t]
lib.hf_frame_from_records.restype = _ptr
# Metadata
lib.hf_frame_nrows.argtypes = [_ptr]
lib.hf_frame_nrows.restype = ctypes.c_int64
lib.hf_frame_ncols.argtypes = [_ptr]
lib.hf_frame_ncols.restype = ctypes.c_int64
lib.hf_frame_schema_json.argtypes = [_ptr]
lib.hf_frame_schema_json.restype = _str
# Aggregations
lib.hf_frame_sum.argtypes = [_ptr, _str]
lib.hf_frame_sum.restype = ctypes.c_double
lib.hf_frame_mean.argtypes = [_ptr, _str]
lib.hf_frame_mean.restype = ctypes.c_double
# Filtering
lib.hf_frame_filter_gt.argtypes = [_ptr, _str, ctypes.c_double]
lib.hf_frame_filter_gt.restype = _ptr
lib.hf_frame_filter_eq_str.argtypes = [_ptr, _str, _str]
lib.hf_frame_filter_eq_str.restype = _ptr
# GroupBy and Sort
lib.hf_frame_groupby_sum.argtypes = [_ptr, _str, _str]
lib.hf_frame_groupby_sum.restype = _ptr
lib.hf_frame_sort_by.argtypes = [_ptr, _str, ctypes.c_int32]
lib.hf_frame_sort_by.restype = _ptr
# Arrow IPC (Chapter 4)
lib.hf_frame_to_arrow_ipc.argtypes = [_ptr, ctypes.POINTER(ctypes.c_size_t)]
lib.hf_frame_to_arrow_ipc.restype = ctypes.POINTER(ctypes.c_uint8)
lib.hf_bytes_free.argtypes = [ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t]
lib.hf_bytes_free.restype = None
Why Type Signatures Matter
Consider what happens if you get a type signature wrong:
# WRONG: restype defaults to c_int (32-bit), but Rust returns *mut Frame (64-bit pointer)
lib.hf_frame_from_csv.restype = None # forgot to set it!
ptr = lib.hf_frame_from_csv(b"data.csv")
# ptr is silently truncated to 32 bits on 64-bit systems
# Next call using ptr -> SEGFAULT or silent corruption
Always set both argtypes and restype for every function. This is the most common source of ctypes bugs.
Section 3: The DataFrame Class
The DataFrame class is a thin Python wrapper around an opaque Rust pointer. No data is stored in Python – all computation runs in the Rust engine.
# hyperframe/frame.py
import json
import ctypes
from .wrapper import lib
class DataFrame:
"""
A Rust-backed columnar DataFrame.
Data is stored in Rust heap memory as contiguous typed arrays.
Python holds only an opaque pointer; all computation runs in Rust.
Memory lifecycle:
1. Rust allocates a Frame and returns a *mut Frame pointer
2. Python wraps it in this class
3. When Python GC collects this object, __del__ calls hf_frame_free()
4. Rust deallocates the Frame
IMPORTANT: Never call hf_frame_free() manually -- let __del__ handle it.
"""
def __init__(self, data: list | None = None):
"""
Create a DataFrame from a list of dicts.
Args:
data: List of dicts, e.g. [{"name": "Alice", "age": 30}, ...]
"""
self._ptr = None
if data is None:
return
if not isinstance(data, list):
raise TypeError(f"Expected list of dicts, got {type(data).__name__}")
if not data:
raise ValueError("Cannot create DataFrame from empty list")
# Serialize to NDJSON and pass bytes to Rust
ndjson = "\n".join(json.dumps(row) for row in data).encode("utf-8")
self._ptr = lib.hf_frame_from_records(ndjson, len(ndjson))
if not self._ptr:
raise RuntimeError("Engine failed to create DataFrame from records")
@classmethod
def _from_ptr(cls, ptr: int) -> "DataFrame":
"""Wrap a raw Rust pointer. Internal use only."""
obj = cls.__new__(cls)
obj._ptr = ptr
return obj
def __del__(self):
"""Free Rust memory when this object is garbage collected."""
if getattr(self, "_ptr", None):
lib.hf_frame_free(self._ptr)
self._ptr = None
# -------------------------------------------------------------------
# Shape and schema
# -------------------------------------------------------------------
@property
def shape(self) -> tuple[int, int]:
"""(rows, columns)"""
return (int(lib.hf_frame_nrows(self._ptr)),
int(lib.hf_frame_ncols(self._ptr)))
@property
def columns(self) -> list[str]:
"""List of column names."""
return list(self._schema().keys())
@property
def dtypes(self) -> dict[str, str]:
"""Column name -> dtype string mapping."""
return self._schema()
def _schema(self) -> dict[str, str]:
raw = lib.hf_frame_schema_json(self._ptr)
return json.loads(raw.decode("utf-8")) if raw else {}
# -------------------------------------------------------------------
# Aggregations
# -------------------------------------------------------------------
def sum(self, column: str) -> float:
"""Parallel column sum (all CPU cores via Rayon)."""
return float(lib.hf_frame_sum(self._ptr, column.encode()))
def mean(self, column: str) -> float:
"""Parallel column mean."""
return float(lib.hf_frame_mean(self._ptr, column.encode()))
# -------------------------------------------------------------------
# Filtering
# -------------------------------------------------------------------
def filter(self, column: str, op: str, value) -> "DataFrame":
"""
Filter rows by condition.
Supported operators: '>' (numeric), '==' (string or numeric)
Examples:
df.filter("price", ">", 100.0)
df.filter("region", "==", "North")
"""
if op == ">":
ptr = lib.hf_frame_filter_gt(
self._ptr, column.encode(), float(value)
)
elif op == "==":
ptr = lib.hf_frame_filter_eq_str(
self._ptr, column.encode(), str(value).encode()
)
else:
raise ValueError(
f"Unsupported operator '{op}'. Use '>' or '=='"
)
if not ptr:
raise RuntimeError(f"Filter failed (col='{column}', op='{op}')")
return DataFrame._from_ptr(ptr)
# -------------------------------------------------------------------
# GroupBy
# -------------------------------------------------------------------
def groupby_sum(self, group_col: str, agg_col: str) -> "DataFrame":
"""
Group by group_col and compute sum of agg_col.
Returns a new DataFrame with columns: [group_col, {agg_col}_sum]
Rows are sorted alphabetically by group_col.
"""
ptr = lib.hf_frame_groupby_sum(
self._ptr, group_col.encode(), agg_col.encode()
)
if not ptr:
raise RuntimeError(
f"groupby_sum failed (group='{group_col}', agg='{agg_col}')"
)
return DataFrame._from_ptr(ptr)
# -------------------------------------------------------------------
# Sorting
# -------------------------------------------------------------------
def sort_by(self, column: str, ascending: bool = True) -> "DataFrame":
"""Sort rows by column value."""
ptr = lib.hf_frame_sort_by(
self._ptr, column.encode(), 1 if ascending else 0
)
if not ptr:
raise RuntimeError(f"sort_by failed on column '{column}'")
return DataFrame._from_ptr(ptr)
# -------------------------------------------------------------------
# Arrow export (Chapter 4)
# -------------------------------------------------------------------
def to_pandas(self):
"""
Convert to pandas DataFrame via Apache Arrow.
Requires: pip install pyarrow pandas
"""
from .arrow import to_pandas
return to_pandas(self)
def to_pyarrow(self):
"""Convert to PyArrow Table."""
from .arrow import to_pyarrow_table
return to_pyarrow_table(self)
# -------------------------------------------------------------------
# Display
# -------------------------------------------------------------------
def __repr__(self) -> str:
rows, cols = self.shape
col_names = self.columns
return f"DataFrame({rows:,} rows x {cols} cols)\nColumns: {col_names}"
Design Decisions
-
No data in Python: The
DataFrameclass stores only_ptr– ac_void_pinteger. There are no Python lists, no numpy arrays, no copies. This means creating a DataFrame costs zero Python memory beyond the object header. -
Lazy imports: The
to_pandas()method importsarrow.pyat call time rather than at module level. This means pandas and pyarrow are optional dependencies – users who don’t need Arrow interop don’t need to install them. -
Factory classmethod:
_from_ptrcreates a DataFrame from an existing pointer without going through__init__. This is used by every method that returns a new DataFrame (filter, groupby, sort).
Section 4: I/O Functions and Public API
# hyperframe/io.py
from .frame import DataFrame
from .wrapper import lib
def read_csv(path: str) -> DataFrame:
"""
Load a CSV file into a DataFrame.
Column types are auto-detected by sampling the first 200 rows.
Supported types: Int64, Float64, Bool, Text.
"""
ptr = lib.hf_frame_from_csv(path.encode("utf-8"))
if not ptr:
raise RuntimeError(
f"Failed to load '{path}'. "
"Check: file exists, valid CSV, check stderr for details."
)
return DataFrame._from_ptr(ptr)
# hyperframe/__init__.py
"""
HyperFrame -- a high-performance Rust-backed DataFrame for Python.
Built progressively throughout the "High-Performance Data Engineering" course.
"""
from .frame import DataFrame
from .io import read_csv
__version__ = "0.1.0"
__all__ = ["DataFrame", "read_csv"]
The public API is intentionally minimal. Users import DataFrame and read_csv; everything else is internal.
Section 5: Memory Ownership – The Most Important Rule
This section is the most critical in the entire course. Getting memory ownership wrong in FFI code produces crashes, corruption, or silent data loss. There are no exceptions or safety nets – you must understand the rules.
Rule 1: Rust Allocates, Rust Frees
Every *mut Frame returned by an hf_* function was created with:
Box::into_raw(Box::new(frame))
This allocates a Frame on the Rust heap and returns a raw pointer. Rust gives up ownership – it will NOT free this memory. The caller (Python) is now responsible for eventually calling hf_frame_free().
Rule 2: Python’s __del__ is the Bridge
When Python’s garbage collector collects a DataFrame object, __del__ is called:
def __del__(self):
if getattr(self, "_ptr", None):
lib.hf_frame_free(self._ptr)
self._ptr = None
This is the ONLY place hf_frame_free() is called. Every allocation flows through this single point of deallocation.
Rule 3: Double-Free is a Crash
If you call hf_frame_free() twice on the same pointer, Rust will attempt to deallocate already-freed memory. In debug builds, this triggers a panic. In release builds, it is undefined behavior – anything from silent corruption to a segfault.
The self._ptr = None guard in __del__ prevents this. After freeing, the pointer is set to None, so a second __del__ call (which can happen in rare GC edge cases) is a no-op.
Rule 4: Borrowed Pointers Are Not Owned
Functions like hf_frame_sum(ptr, col) take *const Frame – a borrowed reference. They read the Frame but do not own it. Never free a borrowed pointer. Only free pointers returned by functions that explicitly transfer ownership (those returning *mut Frame).
Memory Lifecycle Diagram
Python: df = read_csv("data.csv")
|
v
hf_frame_from_csv("data.csv")
|
v [Rust heap]
Box::new(Frame { ... })
Box::into_raw(...) -----------------> raw pointer
|
+-------------------------------------+
v
DataFrame._ptr = raw pointer
|
| (Python uses df.filter(), df.sum(), etc.)
| (Each filter/groupby creates a NEW Frame;
| old one is still alive until GC'd)
|
v [Python GC or del df]
DataFrame.__del__()
|
v
hf_frame_free(raw pointer)
|
v [Rust heap freed]
drop(Box::from_raw(ptr))
Common Pitfall: Chained Operations
# This creates THREE Frames in Rust:
result = (
df.filter("price", ">", 100) # Frame #2 (df is Frame #1)
.groupby_sum("region", "price") # Frame #3
)
# Frame #2 (the intermediate filter result) has no Python reference.
# Python GC will collect it and call hf_frame_free().
# This is correct -- no leak, no double-free.
Each method returns a new DataFrame wrapping a new Rust pointer. The intermediate results are collected by Python’s GC when they go out of scope. This is the correct behavior – you do not need to manually free anything.
Section 6: Error Handling Pattern
Errors cross the FFI boundary in two steps:
Step 1 – Rust side: Functions that can fail return a null pointer and print diagnostics to stderr:
#[no_mangle]
pub extern "C" fn hf_frame_from_csv(path: *const c_char) -> *mut Frame {
match crate::io::read_csv(cstr(path)) {
Ok(f) => Box::into_raw(Box::new(f)),
Err(e) => {
eprintln!("[hyperframe] read_csv error: {e}");
std::ptr::null_mut() // Signal failure
}
}
}
Step 2 – Python side: The wrapper checks for null and raises a Python exception:
ptr = lib.hf_frame_from_csv(path.encode("utf-8"))
if not ptr:
raise RuntimeError(f"Failed to load '{path}'")
This two-level pattern is simple and reliable. The Rust side never panics across the FFI boundary (which would be undefined behavior). The Python side always checks for failure.
Optional Improvement: Structured Error Buffer
For production use, you can add a thread-local error buffer that stores the last error message, retrievable from Python:
// Optional: structured error reporting
thread_local! {
static LAST_ERROR: std::cell::RefCell<String> =
std::cell::RefCell::new(String::new());
}
pub fn set_last_error(msg: &str) {
LAST_ERROR.with(|e| *e.borrow_mut() = msg.to_string());
}
#[no_mangle]
pub extern "C" fn hf_last_error() -> *const c_char {
LAST_ERROR.with(|e| return_str(e.borrow().clone()))
}
Python can then retrieve the error message:
def _get_last_error() -> str:
raw = lib.hf_last_error()
return raw.decode("utf-8") if raw else "Unknown error"
# Usage:
if not ptr:
raise RuntimeError(f"Operation failed: {_get_last_error()}")
Section 7: Project Setup
Directory Structure
hyperframe_sdk/
+-- hyperframe/
| +-- __init__.py
| +-- wrapper.py
| +-- frame.py
| +-- io.py
| +-- arrow.py (Chapter 4)
| +-- libs/
| +-- Linux/
| | +-- libhyperframe_core.so
| +-- Mac/
| | +-- libhyperframe_core-arm64.dylib
| | +-- libhyperframe_core-x86_64.dylib
| +-- Win/
| +-- hyperframe_core.dll
+-- setup.py
Note that the libs/ directory is inside the hyperframe/ package, not at the top level. This is so that package_data can include the binaries when building a wheel.
setup.py
# setup.py
from setuptools import setup, find_packages
setup(
name="hyperframe",
version="0.1.0",
packages=find_packages(),
package_data={"hyperframe": ["libs/**/*"]},
python_requires=">=3.10",
)
Installation
# Build the Rust library first
cd hyperframe-core/
cargo build --release
# Copy binary to the SDK
cp target/release/libhyperframe_core.so \
../hyperframe_sdk/hyperframe/libs/Linux/
# Install the Python package in development mode
cd ../hyperframe_sdk/
pip install -e .
# Verify:
python -c "from hyperframe import DataFrame; print('OK')"
Type Conversion Reference
| Rust Type | ctypes Type | Python Type | Notes |
|---|---|---|---|
i64 | c_int64 | int | 64-bit signed integer |
f64 | c_double | float | 64-bit IEEE 754 |
bool | c_bool | bool | 1 byte |
*const c_char | c_char_p | bytes | Null-terminated C string |
*mut Frame | c_void_p | int | Opaque pointer |
usize | c_size_t | int | Platform-width unsigned |
Summary
In this chapter, you built the complete Python SDK for the Rust engine:
- wrapper.py: Platform-aware library loading and type-safe FFI signatures
- frame.py: DataFrame class with filter, groupby, sort, and aggregation methods
- io.py: CSV loading function
- Memory lifecycle: Rust allocates via
Box::into_raw, Python frees via__del__callinghf_frame_free - Error handling: Null pointer returns from Rust, RuntimeError in Python
- Project structure: Package layout with embedded platform binaries
The Python SDK is now functional. You can load CSV files, filter, aggregate, sort, and group data – all running in Rust at native speed.
Next: Chapter 4 - Apache Arrow: Zero-Copy Interoperability – we add Arrow IPC export to transfer data from Rust to pandas without copying.