FFI and Embedding
You do not always get to start fresh. Most Rust adoption in production happens incrementally: a Python service that needs faster parsing, a Node.js API that needs a memory-safe cryptography module, or a Go service borrowing a library written by another team in Rust. FFI is how those worlds connect.
The boundary is never free. Understanding what it costs — and which tools minimize that cost — determines whether incremental Rust adoption is smooth or painful.
The FFI Model
Rust's FFI operates over the C ABI. Every language with a C FFI (Python, Ruby, Node, Go, Swift, Java via JNI) can call Rust if you expose a C-compatible interface. Rust can call any library that exposes a C ABI — which covers virtually every system library and most legacy codebases.
The C ABI does not carry ownership information. Rust's safety guarantees stop at the boundary. Every unsafe block in FFI code is your promise that you have thought through the ownership, lifetimes, and aliasing rules that Rust would normally enforce for you.
Calling C from Rust
The libc crate provides bindings to the C standard library. For third-party C libraries, bindgen generates Rust bindings from C headers automatically.
// Manual FFI to a C function
extern "C" {
fn strlen(s: *const std::os::raw::c_char) -> usize;
}
fn c_string_len(s: &str) -> usize {
let c_str = std::ffi::CString::new(s).unwrap();
unsafe { strlen(c_str.as_ptr()) }
}
fn main() {
println!("length: {}", c_string_len("hello"));
}For anything beyond trivial calls, use bindgen to generate the bindings:
cargo install bindgen-cli
bindgen /usr/include/mylib.h -o src/bindings.rsThe generated bindings are unsafe by nature. Wrap them in safe abstractions immediately:
// Generated (unsafe) bindings live in a `sys` module
mod sys {
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
}
// Safe public API wraps the unsafe internals
pub struct MyLib {
handle: *mut sys::mylib_t,
}
impl MyLib {
pub fn new() -> Option<Self> {
let handle = unsafe { sys::mylib_create() };
if handle.is_null() { None } else { Some(Self { handle }) }
}
pub fn process(&self, data: &[u8]) -> i32 {
unsafe { sys::mylib_process(self.handle, data.as_ptr(), data.len()) }
}
}
impl Drop for MyLib {
fn drop(&mut self) {
unsafe { sys::mylib_destroy(self.handle) };
}
}The Drop impl ensures the C resource is freed deterministically when the Rust struct goes out of scope — RAII applies here.
Calling Rust from Python with PyO3
PyO3 is the standard tool for writing Python extensions in Rust. It handles the GIL, type conversion, and error propagation between Rust and Python.
# Cargo.toml
[lib]
crate-type = ["cdylib"]
[dependencies]
pyo3 = { version = "0.21", features = ["extension-module"] }use pyo3::prelude::*;
#[pyfunction]
fn parse_log_line(line: &str) -> PyResult<(String, String, String)> {
// Fast Rust parsing exposed to Python
let parts: Vec<&str> = line.splitn(3, ' ').collect();
if parts.len() < 3 {
return Err(pyo3::exceptions::PyValueError::new_err(
"invalid log format"
));
}
Ok((parts[0].to_string(), parts[1].to_string(), parts[2].to_string()))
}
#[pymodule]
fn myparser(_py: Python, m: &PyModule) -> PyResult<()> {
m.add_function(wrap_pyfunction!(parse_log_line, m)?)?;
Ok(())
}Build with maturin:
pip install maturin
maturin develop # builds and installs in the current venvPython then imports it like any C extension:
from myparser import parse_log_line
timestamp, level, message = parse_log_line("2025-01-01 INFO server started")PyO3 releases the GIL during Rust computation by default for #[pyfunction] calls, enabling true parallelism for CPU-bound work called from Python threads.
Calling Rust from Node.js with NAPI-RS
napi-rs provides idiomatic Rust bindings for Node.js native addons. It is significantly more ergonomic than writing raw N-API code.
[lib]
crate-type = ["cdylib"]
[dependencies]
napi = { version = "2", features = ["napi4"] }
napi-derive = "2"use napi_derive::napi;
#[napi]
pub fn hash_password(password: String) -> String {
// CPU-bound work that benefits from leaving the Node.js event loop
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
password.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
#[napi(object)]
pub struct ParsedEvent {
pub id: String,
pub timestamp: u64,
pub payload: String,
}
#[napi]
pub fn parse_event(raw: String) -> napi::Result<ParsedEvent> {
// parse and return structured data
Ok(ParsedEvent {
id: "evt-1".to_string(),
timestamp: 1700000000,
payload: raw,
})
}Build with @napi-rs/cli:
npm install -g @napi-rs/cli
napi build --platform --releaseThe resulting .node file is a native addon importable with require('./mymodule.node').
Cross-Language Boundary Costs
The FFI call itself is cheap — comparable to a function pointer call. The real costs are:
The principle: pass slices of bytes or primitives across the boundary. Avoid converting complex nested objects unless necessary. For streaming or batch processing, pass raw bytes and parse on the Rust side.
Exposing a C ABI from Rust
For callers that speak C FFI directly (Go's cgo, Swift, Java via JNI), compile a staticlib or cdylib with #[no_mangle] exports.
// lib.rs
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
#[no_mangle]
pub extern "C" fn process_data(
input: *const c_char,
input_len: usize,
output_len: *mut usize,
) -> *mut c_char {
let input_bytes = unsafe {
std::slice::from_raw_parts(input as *const u8, input_len)
};
let result = do_processing(input_bytes);
let len = result.len();
unsafe { *output_len = len };
let c_str = CString::new(result).unwrap();
c_str.into_raw() // caller is responsible for freeing
}
#[no_mangle]
pub extern "C" fn free_string(s: *mut c_char) {
if !s.is_null() {
unsafe { drop(CString::from_raw(s)) };
}
}
fn do_processing(data: &[u8]) -> Vec<u8> {
data.iter().map(|b| b.wrapping_add(1)).collect()
}Always provide a corresponding free_* function for any heap-allocated value you return. The caller cannot use Rust's allocator directly.
Key Takeaways
- Rust's FFI operates over the C ABI — any language with a C FFI can call Rust, and Rust can call any C-compatible library.
unsafeat the FFI boundary is unavoidable; contain it in a thinsysmodule and immediately wrap it in a safe public API with proper RAII drop handling.- PyO3 is the most ergonomic path for Python extensions;
maturinhandles the build and packaging pipeline with one command. napi-rsprovides idiomatic Rust-to-Node.js bindings with automatic type conversion for primitives and structs.- The FFI call itself is cheap; the real cost is type conversion — design your boundary around byte slices and primitives, not complex object graphs.
- Always export a matching
free_*function for any heap memory your Rust library returns to a caller; the caller cannot use Rust's allocator.