Ownership Across FFI in Rust Who Frees the Memory Explained
Memory must be freed by the allocator that created it. At an FFI boundary that means every allocation travels with a matching free function on the same side.

Memory bugs at an FFI boundary are rarely about the languages. They are about nobody agreeing who is responsible. The rule that settles most questions is short: whoever allocates, frees, using the same allocator. Examples compile on Rust 1.90, edition 2024.
Why allocators must match
An allocator keeps hidden bookkeeping next to each block, and a free implementation expects its own format. Rust's default allocator on many platforms is the system malloc, but you can replace it with jemalloc or mimalloc, and on Windows the C runtime and the Rust runtime can each own a heap. Passing a pointer to the wrong free corrupts the heap, sometimes silently and much later (custom allocators).
Rust gives C an owned value
use std::ffi::{c_char, CString};
pub struct Config {
pub retries: u32,
}
#[unsafe(no_mangle)]
pub extern "C" fn config_new() -> *mut Config {
Box::into_raw(Box::new(Config { retries: 3 }))
}
#[unsafe(no_mangle)]
pub extern "C" fn config_free(p: *mut Config) {
if !p.is_null() {
// SAFETY: p came from config_new and has not been freed
drop(unsafe { Box::from_raw(p) });
}
}
#[unsafe(no_mangle)]
pub extern "C" fn config_describe(p: *const Config) -> *mut c_char {
if p.is_null() {
return std::ptr::null_mut();
}
// SAFETY: p is non-null and points to a live Config per the API contract
let c = unsafe { &*p };
match CString::new(format!("retries={}", c.retries)) {
Ok(s) => s.into_raw(),
Err(_) => std::ptr::null_mut(),
}
}
#[unsafe(no_mangle)]
pub extern "C" fn string_free(s: *mut c_char) {
if !s.is_null() {
// SAFETY: s came from CString::into_raw in this library
drop(unsafe { CString::from_raw(s) });
}
}Each constructor has a destructor, and the string function names its partner in documentation. A C caller that calls free(str) instead of string_free(str) is the bug to prevent with comments and tests.
C gives Rust an owned value
use std::ffi::c_void;
unsafe extern "C" {
fn make_buffer(len: usize) -> *mut u8; // returns malloc'd memory
fn free_buffer(p: *mut u8); // the library's own free
}
pub struct Buffer {
ptr: *mut u8,
len: usize,
}
impl Buffer {
pub fn new(len: usize) -> Option<Self> {
// SAFETY: plain call, returns null on failure
let ptr = unsafe { make_buffer(len) };
if ptr.is_null() { None } else { Some(Self { ptr, len }) }
}
pub fn as_slice(&self) -> &[u8] {
// SAFETY: ptr is valid for len bytes until Drop
unsafe { std::slice::from_raw_parts(self.ptr, self.len) }
}
}
impl Drop for Buffer {
fn drop(&mut self) {
// SAFETY: ptr came from make_buffer and is freed once
unsafe { free_buffer(self.ptr) };
}
}Never convert such a pointer into a Vec or Box with from_raw_parts. Rust would later free it with its allocator. Keep the foreign pointer in a type whose Drop calls the foreign free. Do not call std::mem::forget on guard types to "hand off" without recording who frees.
Borrowed pointers
A function like const char *config_name(const config *) returns a pointer into the object. Document it as borrowed, valid until the object is freed or modified. In Rust, wrap it as &'a CStr tied to the owner's lifetime:
pub fn name(&self) -> &std::ffi::CStr {
// SAFETY: the library guarantees a NUL terminated string valid as long as self
unsafe { std::ffi::CStr::from_ptr(config_name(self.raw)) }
}The returned reference borrows &self, so the compiler prevents it outliving the object.
Common mistakes
| Mistake | Result | |---|---| | free() on a Box::into_raw pointer | heap corruption | | Box::from_raw on a malloc pointer | allocator mismatch | | Double free (calling destroy twice) | undefined behavior | | Using a pointer after destroy | use after free | | Returning a pointer to a local variable | dangling | | Forgetting the free | leak, found by sanitizers |
Use AddressSanitizer or Valgrind on the combined program, and Miri for the Rust half (sanitizers).
Design checklist
- Name every pointer parameter owned or borrowed in the docs.
- Keep constructor and destructor adjacent in the header.
- Offer opaque handles, not structs with owned fields.
- In Rust wrappers, put the free in
Drop.
Next steps: callbacks, panics across FFI and cbindgen exports.


