How to Use CString and CStr for FFI String Handling

Use CString::new to convert Rust strings to C-compatible null-terminated strings and CStr::from_ptr to safely read C strings back into Rust.

When Rust strings meet C strings

You are writing a Rust wrapper for a C library. You have a string in Rust: "Hello". You pass it to a C function. The C function crashes, or worse, it prints garbage and reads past the end of your buffer. The problem is not your logic. The problem is the null terminator.

Rust strings are just sequences of bytes. They track their length in memory. C strings are sequences of bytes that end with a zero byte. C does not know the length. C reads until it hits a zero. If you pass a Rust string to C, C keeps reading until it finds a zero somewhere in your process memory. You just handed C a key to your entire heap.

CString and CStr exist to bridge this gap. CString takes a Rust string, allocates heap memory, copies the bytes, and appends the zero terminator. CStr takes a raw pointer from C and scans forward until it finds the zero, giving you a safe view of the data.

The contract of the null byte

Think of a CString as a package you are shipping to a warehouse that only understands a specific protocol. You pack your contents, then you attach a bright red "STOP" tag at the end. The warehouse robot reads items until it sees the tag. If you forget the tag, the robot keeps grabbing boxes until it hits a wall.

CString ensures the tag is there. It also refuses to create a package if your contents already contain a "STOP" tag. If your Rust string has a null byte in the middle, CString::new returns an error. This prevents silent data corruption. If you passed "Hel\0lo" to C, C would only see "Hel". Rust would think you sent the whole string. The mismatch causes bugs that are nearly impossible to trace.

CStr is the robot reading the package. It takes a pointer and walks memory until it finds the zero. It does not own the memory. It borrows it. If the memory goes away while CStr is looking at it, you have undefined behavior.

Minimal example: round-trip conversion

This example shows the full flow. Rust string to C pointer, back to Rust string.

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

fn main() {
    // Rust string has no null terminator.
    let rust_str = "Hello, C!";

    // CString::new checks for embedded nulls and appends a terminator.
    // It returns a Result because the input might contain a null byte.
    let c_string = CString::new(rust_str).expect("String contained a null byte");

    // as_ptr returns a raw pointer to the C-compatible buffer.
    // The buffer is valid as long as c_string is alive.
    let c_ptr: *const c_char = c_string.as_ptr();

    // SAFETY: c_ptr is valid because c_string is alive and we just created it.
    // The memory contains a null terminator guaranteed by CString.
    let c_str = unsafe { CStr::from_ptr(c_ptr) };

    // to_string_lossy converts bytes to a Rust String.
    // It replaces invalid UTF-8 sequences with the replacement character.
    let back_to_rust = c_str.to_string_lossy();
    println!("{}", back_to_rust);
}

Convention aside: The community prefers CString::new over manual allocation. It handles the null check and allocation in one step. If you see code manually allocating a Vec<u8> and pushing a zero, rewrite it to use CString. The compiler optimizes CString well, and the intent is clearer.

What happens under the hood

When you call CString::new, Rust allocates a buffer on the heap. It copies the bytes from your string slice. It appends a single zero byte. The CString struct holds a pointer to this buffer and its length. The length includes the zero byte.

When you call as_ptr, CString returns the address of the buffer. It does not move the data. The CString still owns the memory. You can call as_ptr multiple times. The pointer remains valid until c_string is dropped.

When you call CStr::from_ptr, you enter unsafe territory. The function takes the pointer and scans memory byte by byte. It stops at the first zero. It returns a &CStr that borrows the memory. The lifetime of the &CStr is tied to the lifetime of the data behind the pointer. If the data is a CString, the &CStr must not outlive the CString.

When you call to_string_lossy, CStr reads the bytes it found. It checks if they are valid UTF-8. If they are, it returns a borrowed &str wrapped in a Cow. If they are not, it allocates a new String, replaces bad bytes with , and returns that. Cow stands for Clone on Write. It avoids allocation when the data is already valid UTF-8.

Convention aside: Use to_string_lossy for C strings unless you have a guarantee of UTF-8. C strings are often just bytes. They might contain Latin-1, Shift-JIS, or binary data. to_str panics on invalid UTF-8. to_string_lossy keeps your program running and gives you a readable result.

Realistic example: calling a C function

In real code, you call extern "C" functions. The CString must stay alive for the duration of the call.

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

/// Simulates a C function that takes a null-terminated string.
extern "C" {
    fn c_print_message(msg: *const c_char);
}

/// Calls a C function with a Rust string.
fn call_c_function() {
    // Create the C string. This allocates and appends the null terminator.
    let message = CString::new("Rust calling C!").expect("Null byte in string");

    // Pass the pointer to C.
    // The CString must stay alive for the duration of the call.
    // If message were dropped here, the pointer would dangle.
    unsafe {
        c_print_message(message.as_ptr());
    }
}

/// Receives a string from C and prints it.
fn receive_from_c(c_ptr: *const c_char) {
    // SAFETY: The caller guarantees c_ptr points to a valid null-terminated string.
    // The string must remain valid for the duration of this function.
    let c_str = unsafe { CStr::from_ptr(c_ptr) };

    // Convert to Rust string, handling potential encoding issues.
    let rust_str = c_str.to_string_lossy();
    println!("C said: {}", rust_str);
}

Convention aside: Keep unsafe blocks small. Wrap only the FFI call or the pointer dereference. In call_c_function, the unsafe block contains only the call to c_print_message. The CString creation and as_ptr are safe operations. This isolates the risk and makes the code easier to audit.

Ownership transfer: handing memory to C

Sometimes C needs to own the string. C might store the pointer and free it later. If you pass a CString pointer and let the CString drop, Rust frees the memory. C then accesses freed memory. This is a use-after-free bug.

Use CString::into_raw to transfer ownership. It consumes the CString and returns a raw pointer. Rust forgets about the memory. C is responsible for freeing it.

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

/// Transfers ownership of a string to C.
fn give_string_to_c() -> *mut c_char {
    // Create the C string.
    let c_string = CString::new("Owned by C").expect("Null byte");

    // into_raw consumes the CString and returns a raw pointer.
    // Rust no longer owns this memory. C must free it.
    c_string.into_raw()
}

/// Takes back ownership of a string from C.
fn take_string_from_c(ptr: *mut c_char) {
    // SAFETY: ptr must be a valid pointer returned by CString::into_raw.
    // It must not have been freed by C.
    let _c_string = unsafe { CString::from_raw(ptr) };

    // When _c_string drops, Rust frees the memory.
}

Convention aside: into_raw is not a memory leak. It is a handoff. The community calls this "leaking to C". Document clearly that C owns the memory. If C uses free, ensure the allocation strategy matches. CString uses the system allocator, so free works on most platforms. If C uses a custom allocator, you cannot use CString::into_raw. You must allocate with the custom allocator yourself.

Pitfalls and compiler errors

Embedded null bytes break CString. If your input comes from user data or a file, it might contain nulls. CString::new returns Err in this case. Handle the error. Do not unwrap blindly.

let bad_str = "Hel\0lo";
let result = CString::new(bad_str);
// result is Err(CStringError { input: ..., null_byte: 3 })

Dangling pointers cause undefined behavior. If you create a CString, pass its pointer to C, and then drop the CString while C is still using it, C reads garbage. The compiler cannot catch this. You must manage lifetimes manually.

If you try to return a &CStr from a function where the CString is local, the compiler rejects you with E0597 (borrowed value does not live long enough). The CString is dropped at the end of the function. The &CStr would point to freed memory.

fn bad_example() -> &'static CStr {
    let c_string = CString::new("Hello").unwrap();
    // E0597: c_string does not live long enough
    unsafe { CStr::from_ptr(c_string.as_ptr()) }
}

Fix this by returning the CString itself, or by using into_raw if C needs ownership.

Type mismatches happen when you pass &str where *const c_char is expected. The compiler rejects this with E0308 (mismatched types). You must convert to CString first.

// E0308: mismatched types
// expected *const c_char, found &str
unsafe { c_print_message("Hello".as_ptr()) }; // Error: &str has no as_ptr

Convention aside: Use c_char for FFI types, not i8. c_char is a type alias that matches the C char type on the target platform. On most systems it is i8, but using c_char makes your code portable and signals intent.

Decision matrix

Use CString when you need to pass a Rust string to C and must guarantee a null terminator. Use CString::new for simple conversions. Use CString::from_vec_with_nul when you already have a Vec<u8> that ends with a zero and want to avoid a second allocation.

Use CStr::from_ptr when you receive a raw pointer from C and need to read the string safely until the null byte. Use to_string_lossy to convert to a Rust String unless you guarantee UTF-8. Use to_bytes if you need the raw bytes including the terminator.

Use &str when you are working entirely within Rust and do not need FFI compatibility. Use String when you need an owned Rust string. Never pass &str or String directly to C.

Use Vec<u8> when you are dealing with binary data that might contain null bytes or is not text at all. C strings cannot represent embedded nulls. If your data has nulls, pass a pointer and a length, not a CString.

Use CString::into_raw when you need to hand ownership of the string to C so C can free it. Use CString::from_raw when C returns ownership back to Rust. Treat the raw pointer as a token of ownership. If you lose the token, you leak memory.

Where to go next