How to Use CString and CStr for FFI in Rust

Use CString to send Rust strings to C and CStr to read C strings back into Rust safely.

The border problem

You are wrapping a C library in Rust. You pass a filename to fopen. The C function segfaults deep inside the standard library. You check your Rust string. It looks perfect. "config.txt". No weird characters. The problem isn't your string. It's the boundary.

C expects a null byte at the end of every string. Rust strings don't have one. Worse, C strings cannot contain a null byte in the middle. If your Rust string has a null byte, you are handing C a lie. C will think the string ended early, or it will read past the end of your buffer until it finds a random zero byte in memory.

Rust strings are length-prefixed. A &str is a pointer and a length. C strings are null-terminated. A C string is just a pointer. The length is found by scanning for \0. This mismatch is where FFI bugs hide. CString and CStr exist to bridge this gap safely.

CString: packing for C

CString is the type you use when sending data from Rust to C. It owns the memory. It ensures the data is valid C before you cross the border.

When you call CString::new, Rust scans your string for internal null bytes. If it finds one, it returns an error. You cannot create a CString with a null byte inside. This prevents you from passing invalid data to C. If the scan passes, CString allocates memory on the heap, copies your string, and appends a null byte.

The allocation looks like this: [H][e][l][l][o][\0]. The length is implicit. C will read until it hits the \0.

CString implements Drop. When the CString goes out of scope, the memory is freed. This is crucial. If you pass a pointer from CString to C, and the CString drops while C is still using the pointer, you have a dangling pointer. C will read freed memory. That is undefined behavior.

use std::ffi::CString;
use std::os::raw::c_char;

fn prepare_for_c(rust_str: &str) -> Result<CString, std::ffi::NulError> {
    // CString::new returns a Result. It fails if rust_str contains a null byte.
    // This check happens at runtime, not compile time.
    let c_string = CString::new(rust_str)?;

    // Convention: use expect with a message in main or top-level code.
    // The error message tells you exactly which string failed.
    // c_string.expect("Filename contained a null byte");

    Ok(c_string)
}

fn main() {
    let safe_str = "hello";
    let c_str = prepare_for_c(safe_str).unwrap();

    // as_ptr returns a *const c_char.
    // The CString still owns the memory.
    let ptr: *const c_char = c_str.as_ptr();

    // You can pass ptr to C here.
    // The memory is valid as long as c_str is alive.
    println!("Pointer: {:?}", ptr);
}

Treat the null terminator as the border guard. Without it, C walks right off the edge of your memory.

CStr: reading from C

CStr is the type you use when receiving data from C. It is a borrowed view of C string data. It does not own the memory. It wraps a pointer and treats the data as a null-terminated string.

You create a CStr using CStr::from_ptr. This function is unsafe. Rust cannot verify that the pointer is valid, that it points to a null-terminated string, or that the data won't change while you read it. You must guarantee these things.

CStr provides methods to convert the data back to Rust types. to_str returns a Result<&str, Utf8Error>. It checks if the bytes are valid UTF-8. If they are not, it returns an error. C strings are bytes, not necessarily text. They might be Latin-1, UTF-16, or binary data. to_str fails on anything that isn't UTF-8.

to_string_lossy returns a Cow<str>. It replaces invalid UTF-8 sequences with the replacement character . This is useful for logging or display where you want to see the data even if it's malformed. to_bytes returns the raw bytes without the null terminator. to_bytes_with_nul includes the null terminator.

use std::ffi::{CStr, CString};
use std::os::raw::c_char;

fn read_from_c(c_ptr: *const c_char) -> String {
    // SAFETY:
    // 1. c_ptr is valid and points to allocated memory.
    // 2. The memory is null-terminated within the bounds.
    // 3. The data is not mutated while CStr is used.
    let c_str = unsafe { CStr::from_ptr(c_ptr) };

    // to_string_lossy handles non-UTF8 data gracefully.
    // Use to_str if you need strict UTF-8 validation.
    c_str.to_string_lossy().into_owned()
}

fn main() {
    let rust_str = "Hello from Rust";
    let c_string = CString::new(rust_str).unwrap();

    // Simulate receiving a pointer from C.
    let ptr = c_string.as_ptr();

    let result = read_from_c(ptr);
    println!("{}", result);
}

Trust the borrow checker here. CStr borrows the pointer. If the underlying memory is freed, the CStr becomes invalid. Keep the source alive.

The ownership transfer pattern

Sometimes C needs to own the string. You pass a pointer to C, and C stores it for later use. C will free the memory when it's done. In this case, you cannot use CString directly. If CString drops, it frees the memory, and C gets a dangling pointer.

Use CString::into_raw. This converts the CString into a raw pointer and leaks the memory. The memory is not freed. You must reclaim it later using CString::from_raw. If you forget to call from_raw, you leak memory.

This pattern is dangerous. It requires manual memory management. Use it only when the C API demands ownership transfer.

use std::ffi::CString;
use std::os::raw::c_char;

fn transfer_ownership(rust_str: &str) -> *mut c_char {
    let c_string = CString::new(rust_str).expect("Null byte in string");

    // into_raw consumes the CString and returns a *mut c_char.
    // The memory is leaked. Rust will not free it.
    // You must call CString::from_raw later to reclaim it.
    c_string.into_raw()
}

fn reclaim_ownership(c_ptr: *mut c_char) {
    // SAFETY:
    // 1. c_ptr was created by CString::into_raw.
    // 2. c_ptr has not been freed yet.
    // 3. c_ptr is aligned and valid.
    unsafe {
        // from_raw reclaims the pointer and converts it back to a CString.
        // The CString will free the memory when it drops.
        let _c_string = CString::from_raw(c_ptr);
    }
}

fn main() {
    let ptr = transfer_ownership("owned by C");
    println!("Pointer handed to C: {:?}", ptr);

    // Simulate C finishing with the string.
    reclaim_ownership(ptr);
}

If you call into_raw, you are the garbage collector now. Write the from_raw call immediately, or leak.

Pitfalls and compiler errors

Internal null bytes

CString::new fails if the string contains a null byte. This is a runtime error, not a compile-time error. If you unwrap blindly, your program panics. Handle the Result.

Convention: Use expect with a descriptive message. "Input contained null byte" is better than "called Result::unwrap() on an Err value".

Dangling pointers

Passing as_ptr to C and letting CString drop is a common mistake. The pointer becomes invalid. C reads garbage.

Solution: Keep the CString alive for the duration of the C call. Or use into_raw if C takes ownership.

UTF-8 assumptions

C strings are bytes. They are not UTF-8. CStr::to_str panics if the data is not valid UTF-8. Use to_string_lossy for display. Use to_bytes if you need raw bytes.

c_char portability

Always use std::os::raw::c_char. Do not use i8 or u8 directly. c_char is i8 on most platforms, but u8 on some Windows targets. Using the wrong type causes type mismatches and potential sign-extension bugs.

If you try to pass a &str directly to a C function expecting *const c_char, the compiler rejects you with E0308 (mismatched types). Rust strings are not pointers. They are fat pointers with a length. You must convert them.

CStr::from_ptr on invalid data

Calling CStr::from_ptr on a dangling pointer, an unaligned pointer, or a pointer to non-null-terminated data is undefined behavior. The compiler cannot catch this. You must verify the pointer before calling from_ptr.

Treat the SAFETY comment as a proof. If you can't write the invariants, you don't have one.

Realistic example: calling a C function

Here is a complete example of calling a C function that takes a string and returns an integer. The C function signature is declared using extern "C". The Rust code handles the conversion and error handling.

use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_int};

// Declare the C function.
// The ABI is "C". The argument is a pointer to c_char.
extern "C" {
    fn process_name(name: *const c_char) -> c_int;
}

/// Calls the C function process_name with a Rust string.
/// Returns the result from C, or an error if the string is invalid.
fn call_process_name(name: &str) -> Result<i32, std::ffi::NulError> {
    // CString::new validates the string and allocates memory.
    // It returns Err if name contains a null byte.
    let c_name = CString::new(name)?;

    // Call the C function.
    // c_name is alive during the call, so the pointer is valid.
    let result = unsafe {
        // SAFETY:
        // 1. c_name.as_ptr() returns a valid pointer to null-terminated data.
        // 2. process_name is declared with the correct signature.
        // 3. process_name does not store the pointer beyond the call.
        process_name(c_name.as_ptr())
    };

    Ok(result)
}

fn main() {
    match call_process_name("Alice") {
        Ok(code) => println!("C returned: {}", code),
        Err(e) => eprintln!("Invalid string: {:?}", e),
    }
}

Don't fight the compiler here. Reach for CString and CStr. They handle the heavy lifting.

Decision matrix

Use CString::new when you need to pass a Rust string to C and want the compiler to verify there are no internal null bytes. Use CString::into_raw when you must hand ownership of the string to C code that will free it later, and you have a matching CString::from_raw call to reclaim it. Use CStr::from_ptr when you receive a pointer from C and need to read it as a Rust string slice, ensuring the pointer is valid and null-terminated. Use CStr::from_bytes_with_nul when you have a byte slice in Rust that represents a C string and you want to parse it without crossing the FFI boundary. Reach for &str when you are staying entirely within Rust; crossing the FFI boundary always requires conversion.

Counter-intuitive but true: the more you use unsafe, the harder the rest of your code becomes to reason about. Keep the unsafe blocks small and isolated.

Where to go next