Embedding and Being Embedded: Rust Interop
Every time Rust code talks to Python, Node.js, or C/C++, two runtimes that know nothing about each other's internals have to cooperate across a hard line with no compiler on the other side to check assumptions. Getting comfortable with native interop means treating that line as a contract: an explicit, renegotiated-on-every-call agreement about who owns what memory, how errors are signaled, and which runtime is allowed to assume it is in control.
This page is the conceptual anchor for the native-interop section. The other pages here show you how to write the contract for a specific host - PyO3 for Python, napi-rs for Node, cbindgen for C/C++. This page explains why the contract has the shape it does, so those tool-specific pages read as instances of one idea instead of three unrelated recipes.
Summary
- An FFI boundary is a contract both languages must honor about memory layout, ownership transfer, error signaling, and threading assumptions, because the compiler that enforces safety on one side has no visibility into the other.
- Insight: Rust's safety guarantees stop at
unsafe extern "C"blocks; every bug that native interop introduces is a violation of an assumption one side made that the other side didn't share. - Key Concepts: ABI (application binary interface), ownership transfer, embedding vs being embedded, panic boundary, GC boundary, calling convention.
- When to Use: Accelerating a Python or Node hot path with Rust, exposing a Rust core library to C/C++ consumers, or embedding a scripting language inside a Rust-owned process.
- Limitations/Trade-offs: No FFI boundary is memory-safe by construction; safety at the boundary is a property you build and maintain by convention, not one Rust gives you for free.
- Related Topics: stable ABIs, garbage collection, panic unwinding, calling conventions, WASM sandboxing.
Foundations
At its simplest, foreign function interface (FFI) work means calling code written in one language from another, or letting another language call into yours. The interesting part is not the call itself; it is everything the call implicitly depends on that a function call within one language takes for granted. A Rust function call trusts the compiler to check argument types, manage the stack, and guarantee the callee will not leave shared data in an invalid state. Across an FFI boundary none of that trust exists, because the two sides were compiled by different toolchains against different assumptions about memory layout.
The first question every interop decision answers is: who owns main? If Rust owns the process entry point and initializes an embedded scripting runtime inside it, Rust is embedding the other language - this is the shape of a Rust CLI hosting a Python plugin, or any Rust application that hosts a plugin language. If the other runtime owns the process and loads Rust as a library, Rust is being embedded - this is the shape of a Python extension built with PyO3 and maturin, a Node native addon built with napi-rs, or a cdylib consumed by a C++ application. The direction of ownership decides who controls initialization order, who installs signal handlers, and which allocator is authoritative for the process.
Whichever direction the relationship runs, both sides need to agree on an ABI: a stable, binary-level description of how arguments are passed, how structs are laid out in memory, and what calling convention is used. Rust's own in-language layout (repr(Rust)) is intentionally unstable between compiler versions, because the compiler is free to reorder struct fields for optimization. The moment a type crosses a language boundary it must be pinned to repr(C), and functions must be declared extern "C", so both compilers agree on the exact same bytes. Think of the boundary like passport control between two countries: your internal documents are not valid on the other side. You present a standardized document that both countries have agreed to honor, and nothing more.
Mechanics & Interactions
The contract has four load-bearing clauses that recur across every host: ownership, panics, memory management model, and threading.
Ownership is the most consequential clause. Every pointer, buffer, or handle that crosses the boundary needs an unambiguous answer to "who frees this?" Rust's ownership model tracks this automatically, but that tracking evaporates the instant a value leaves Rust's type system as a raw pointer. The convention that survives in practice: whichever side allocated a resource is the only side allowed to free it, exposed through an explicit _free or _destroy function rather than the host's own deallocation path. A Node Buffer freed by Rust's allocator, or a Rust Vec freed by C's free(), is a double-free or a corrupted heap waiting to happen, because the two allocators do not share bookkeeping.
Panics are the clause newcomers most often get wrong. A Rust panic unwinds the stack looking for a catch_unwind boundary; if it reaches an extern "C" function without being caught, the behavior is undefined, because C has no concept of Rust's unwinding mechanism to hand control back to. Every exported entry point that can panic needs to wrap its body in std::panic::catch_unwind and convert the result into an error code the host understands. This is not a style preference; it is the difference between a recoverable error and a crashed host process.
Memory management model mismatches show up hardest at the Python and Node boundaries, because both host a garbage collector while Rust does not. PyO3's Python<'_> token exists to prove, at compile time, that the calling code holds Python's Global Interpreter Lock (GIL) before it touches Python objects - without that proof, Rust code could observe a Python object mid-collection. napi-rs faces the inverse problem: JavaScript's engine is single-threaded by convention, so calling back into JS from a Rust-spawned OS thread requires a ThreadsafeFunction that marshals the call onto the JS event loop rather than invoking it directly. Both mechanisms answer the same underlying question: which runtime's garbage collector may assume exclusive knowledge of live references at any instant?
Threading assumptions compound all of the above. A boundary designed assuming single-threaded, synchronous calls will silently corrupt state the moment either side introduces concurrency without renegotiating the contract - which is why "is this safe from multiple threads" belongs in the header or docstring of every exported function, not left to be discovered.
// Panics never cross the boundary uncaught; this is why every exported
// entry point wraps its body, not just the "risky" ones.
#[no_mangle]
pub extern "C" fn engine_step(handle: *mut Engine) -> i32 {
let result = std::panic::catch_unwind(|| {
let engine = unsafe { &mut *handle };
engine.step() // may panic internally
});
match result {
Ok(_) => 0,
Err(_) => -1, // host sees an error code, never an unwind
}
}The snippet above is not about the mechanics of Engine::step; it is about the shape every FFI entry point needs regardless of what it does internally, because the alternative is a host process that can crash from a Rust-side bug it has no way to observe or recover from.
Advanced Considerations & Applications
ABI stability is not uniform across hosts, and that difference drives real tooling decisions. The C ABI itself is effectively permanent - it has not meaningfully changed in decades, which is why extern "C" is the lingua franca every other interop tool eventually compiles down to. Python's C API, by contrast, is versioned and can shift between minor releases; PyO3 addresses this with the abi3 feature, targeting Python's stable ABI subset so one compiled wheel works across multiple Python minor versions instead of rebuilding per release. Node's native module story solved the equivalent problem with N-API, a versioned, backward-compatible ABI that napi-rs targets by default, which is why prebuilt .node binaries ship without requiring consumers to own a Rust toolchain.
This is also where the four native-interop strategies genuinely diverge, and picking the right one is an architectural decision, not a preference:
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
| PyO3 + maturin | Deep Python type integration, GIL-aware safety | Extension churns if not built against abi3 | Accelerating Python hot paths, keeping orchestration in Python |
| napi-rs | Stable N-API ABI, generates TypeScript types | Async and threading need explicit runtime wiring | CPU-bound Node workloads shipped as npm packages |
extern "C" + cbindgen | Universally stable, zero runtime dependency | No safety net at all; caller can violate any assumption | SDKs, mobile bindings, legacy C/C++ integration |
| wasm-bindgen / WASM | Sandboxed, portable across browser and edge | Copy-heavy boundary, restricted syscall surface | Untrusted code, cross-platform plugins without per-OS builds |
The deeper point behind that table is that WASM is not merely "native FFI but slower" - it trades direct memory access for a sandboxed trust model, which is why it is the right answer when the code on the other side is not trusted, not just when portability matters.
Observability at the boundary deserves an honest note: a segfault originating in FFI code gives a stack trace that stops at the language boundary from the host's perspective, and tools like valgrind, sanitizers, or cargo miri only see the Rust side. Debugging a boundary bug usually means reproducing it in isolation on the Rust side first, because the host's own debugger has no visibility into what an unsafe block actually did.
Common Misconceptions
- "FFI is just calling a Rust function from another language." - It behaves like a function call syntactically, but semantically it is a renegotiated contract with no compiler enforcement on either end.
- "
repr(Rust)structs are close enough to C layout to just cross the boundary." - The compiler reorders fields for optimization and gives no layout guarantee between versions; onlyrepr(C)types have a defined, stable layout. - "Once a wheel or native addon is built, its ABI is stable forever." - True for
extern "C"and, with care, forabi3wheels and N-API addons, but not automatic; skipping the stable-ABI feature means rebuilding on every host runtime bump. - "A Rust panic behaves like an exception the host language can catch." - It does not; an uncaught panic reaching an
extern "C"boundary is undefined behavior, not a catchable error. - "Passing an owned
StringorVecacross FFI is fine because Rust manages memory automatically." - Ownership tracking is a Rust-internal, compile-time-only guarantee; once a value crosses into raw pointer form the automatic bookkeeping is gone and manual convention takes over. - "WASM exists because native FFI is too hard." - WASM exists because some code needs a sandbox that native FFI structurally cannot provide, not as a simpler substitute for the same problem.
FAQs
What exactly is "the FFI boundary," if it's not just a function call?
It is the point where compile-time guarantees from one language's compiler stop applying. Past that point, correctness depends on both sides honoring an unenforced convention about memory layout, ownership, and error signaling.
Why can't a Rust panic just propagate into C code like an exception?
C has no unwinding mechanism compatible with Rust's panic implementation. An unwind that reaches an extern "C" frame without being caught is undefined behavior, not a graceful error, so every exported entry point wraps its body in catch_unwind.
How does PyO3 keep Rust code from corrupting Python's garbage collector?
Through the Python<'_> token, which can only be obtained while the GIL is held. Its presence in a function signature is a compile-time proof that it's safe to touch Python objects at that point.
Why does napi-rs need `ThreadsafeFunction` just to call back into JavaScript?
Because JavaScript engines assume single-threaded execution. Calling a JS function directly from a Rust-spawned OS thread would violate that assumption; ThreadsafeFunction marshals the call back onto the JS event loop instead.
Is the C ABI actually standardized somewhere?
Not by a single formal spec, but in practice it has been stable across platforms and toolchains for decades, which is why every other interop layer (PyO3, napi-rs, wasm-bindgen) ultimately compiles down to extern "C" conventions underneath.
Why can't I just pass a Rust `String` across the boundary?
String carries a Rust-internal layout (pointer, length, capacity) with no defined C representation, and its memory is owned by Rust's allocator. The host has no way to safely read or free it directly; convert to a CString or byte buffer with explicit ownership rules instead.
When should Rust embed a host language versus being embedded by one?
Embed the other language when a Rust-owned process needs to script or extend itself (a Rust CLI running Python plugins). Be embedded when the host application already exists and you're accelerating a piece of it (a Python or Node package with a Rust core).
Is a WASM boundary actually slower than a native FFI boundary?
Usually, yes, because WASM calls typically involve copying data across a sandboxed linear memory instead of sharing pointers directly. The trade is intentional: you're buying isolation, not just portability.
Why does ABI stability matter more for Python extensions than for a raw C library?
A raw C library targets the permanently stable C ABI. A Python extension targets Python's own C API, which is versioned and can change between minor releases unless the extension explicitly opts into the abi3 stable subset.
How do I know who is responsible for freeing a given pointer across a boundary?
Whichever side allocated the memory is the only side that frees it, exposed through an explicit function (engine_destroy, rust_string_free) rather than the host's native deallocation path.
Can an FFI boundary ever be made fully memory-safe?
Not by construction. Crates like cxx and typed wrappers like PyO3's #[pyclass] shrink the surface area for mistakes, but the boundary always has an unsafe core that depends on a convention being honored, not a guarantee being enforced.
Why do teams usually pick one interop tool per boundary instead of mixing approaches?
Each tool (PyO3, napi-rs, cbindgen) encodes one internally consistent contract for its target host: ownership rules, panic handling, threading model. Mixing tools for the same boundary means reconciling two sets of assumptions about the same memory, multiplying the ways the contract can be violated.
Related
- Interop Basics - the decision matrix for picking a boundary tool per host
- PyO3 - the Python-specific instance of this contract, including GIL rules
- Node.js with napi-rs - the Node-specific instance, including N-API stability
- Data Marshalling - concrete patterns for the ownership clause of the contract
- Building Sound Bindings - soundness discipline for the raw
extern "C"layer underneath every host-specific tool
Stack versions: This page is conceptual, but its examples assume Rust 1.97.0 (edition 2024) and the tool versions used elsewhere in this section: PyO3 targeting Python's
abi3stable ABI, and napi-rs targeting N-API.