The ownership contract
You spent years writing C. You know exactly how malloc works, you've debugged segfaults at 2 AM, and you trust errno to tell you when a socket failed. Now you're looking at Rust. The syntax feels familiar, but the compiler acts like a strict project manager. It refuses to compile code that looks perfectly fine. It complains about variables you already freed. It blocks you from passing a pointer to a function. This isn't a bug. It's a different contract.
C gives you raw memory access and expects you to track every allocation manually. Rust replaces that manual tracking with compile-time rules. The core idea is ownership. Every value has exactly one owner. When the owner leaves scope, the value is cleaned up automatically. You don't call free. You don't manage reference counts. The compiler inserts the cleanup code for you.
Borrowing is how you share data without transferring ownership. You hand out references instead of pointers. The compiler tracks how long each reference lives. If you try to use a reference after the original data is gone, the code won't compile. Think of it like a library system. The book has one official catalog record. You can check out copies of the record to read, or you can check out the original to edit it. You can't edit the book while someone else is reading it. The system enforces this at the desk, not after the book gets torn.
Rust also splits memory into two zones. The stack holds fixed-size data with known lifetimes. The heap holds dynamic data that lives until you explicitly drop it. In C, you decide where things go. In Rust, the compiler decides based on type size and lifetime. A String owns its heap buffer. A &str is just a pointer and a length. The compiler guarantees the pointer never outlives the buffer.
Stop thinking about where memory lives. Start thinking about who is responsible for it.
Minimal example
Here's the smallest case: a basic TCP listener that accepts a connection, reads a line, and sends a response.
use std::net::{TcpListener, TcpStream};
use std::io::{BufReader, Read, Write};
/// Handles a single incoming TCP connection and sends a basic response.
fn handle_connection(mut stream: TcpStream) {
// BufReader buffers input so we don't make a syscall for every byte.
let mut buf_reader = BufReader::new(&stream);
let mut request = String::new();
// Read until the first newline. Rust strings don't need null terminators.
buf_reader.read_line(&mut request).unwrap();
// Write a minimal HTTP response back to the client.
let response = "HTTP/1.1 200 OK\r\n\r\nHello from Rust\n";
stream.write_all(response.as_bytes()).unwrap();
}
fn main() {
// Bind to localhost on port 7878. unwrap() panics if the port is already taken.
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
// incoming() returns an iterator over accepted connections.
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
What actually happens
When you compile this, the Rust compiler runs a borrow checker alongside the type checker. It maps out every variable's lifetime. When handle_connection finishes, stream goes out of scope. The compiler automatically inserts a drop call that closes the socket and frees the underlying file descriptor. You don't write close(fd). You don't leak.
At runtime, the code behaves like a standard C network loop. The difference is in the failure path. In C, a failed accept returns -1 and sets errno. You check the return value manually. In Rust, listener.incoming() yields a Result<TcpStream, std::io::Error>. The unwrap() call extracts the stream or panics. Production code replaces unwrap() with match or the ? operator to propagate errors up the call stack. The compiler forces you to acknowledge the failure case. You can't accidentally ignore it.
Convention dictates using ? in functions that return Result, and avoiding unwrap() in library code. Reserve unwrap() for examples and quick scripts where a panic is acceptable. The community treats unwrap() like a TODO comment. It works, but it signals that error handling is deferred.
Treat every Result like a loaded gun. Point it at the caller with ?, or catch it with match.
Realistic example
Real services need concurrency. In C, you'd spawn a thread with pthread_create and pass a pointer to a shared struct. Rust handles threads safely by requiring explicit ownership transfer or synchronization primitives. Here's how you spin up a thread for each connection without sharing mutable state.
use std::net::TcpListener;
use std::thread;
use std::io::{BufReader, Read, Write};
/// Processes a connection in a separate thread to keep the main loop responsive.
fn handle_connection(mut stream: TcpStream) {
let mut buf_reader = BufReader::new(&stream);
let mut request = String::new();
// Read the first line of the request.
if buf_reader.read_line(&mut request).is_ok() {
let response = "HTTP/1.1 200 OK\r\n\r\nHandled in a thread\n";
// Discard the write result intentionally. We don't need to log partial writes here.
let _ = stream.write_all(response.as_bytes());
}
}
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
// spawn() takes ownership of the stream and runs it concurrently.
thread::spawn(move || {
handle_connection(stream);
});
}
}
The move keyword is the C equivalent of passing a pointer by value, but with a twist. It transfers ownership of stream into the closure. The closure now owns the socket. When the thread finishes, the socket closes. If you tried to use stream after thread::spawn, the compiler would reject it with E0382 (use of moved value). The data can only live in one place at a time.
Pass ownership, not pointers. Threads that own their data can't race.
Talking to C code
You won't rewrite your entire C codebase in a weekend. Rust makes FFI straightforward. You declare external functions with extern "C" and call them from an unsafe block. The unsafe keyword doesn't mean the code is dangerous. It means you are taking responsibility for invariants that the compiler cannot verify. Here's the smallest case: a safe wrapper around a C function that processes a raw byte buffer.
extern "C" {
/// C library function that processes a raw byte buffer.
fn c_process_buffer(data: *const u8, len: usize) -> i32;
}
/// Safely wraps the C call by guaranteeing the pointer is valid and non-null.
fn safe_process_buffer(data: &[u8]) -> i32 {
// SAFETY: data.as_ptr() is valid for the entire slice length.
// The slice guarantees non-null, aligned, and properly initialized memory.
unsafe { c_process_buffer(data.as_ptr(), data.len()) }
}
The community calls this the minimum unsafe surface rule. Keep the unsafe block as small as possible. Wrap it in a safe function that enforces the preconditions. Document the invariants in a // SAFETY: comment. If you can't write the proof, you don't have one.
Treat the SAFETY comment as a contract. Break it and you get undefined behavior.
Pitfalls and compiler errors
C programmers hit three walls consistently when switching to Rust. The first is treating references like pointers. In C, a pointer can dangle. In Rust, a reference is guaranteed to point to valid memory. If you try to return a reference to a local variable, the compiler stops you with E0515 (cannot return value referencing local data). You must return an owned value or extend the lifetime explicitly.
The second is ignoring errors. C code often skips if (ptr == NULL) checks. Rust replaces NULL with Option<T> and error codes with Result<T, E>. If you try to use an Option as a concrete value, you get E0308 (mismatched types). The compiler forces you to unwrap, match, or propagate.
The third is fighting the borrow checker with mutable state. C developers reach for global variables or static buffers. Rust isolates mutable state behind references or interior mutability types. If you try to mutate a value while an immutable reference exists, you hit E0502 (cannot borrow as mutable because it is also borrowed as immutable). The fix is usually to restructure the code so the mutable operation happens in a separate scope, or to use RefCell<T> for runtime borrow checking when compile-time tracking is too rigid.
Convention favors let _ = value when you intentionally drop a result. It signals to readers that you considered the value and chose to discard it.
Don't fight the compiler here. Reach for RefCell or split your scopes.
Decision matrix
Use Box<T> when you need heap allocation for a single value and want automatic cleanup on drop. Use Vec<T> when you need a dynamically sized array that manages its own capacity and memory. Use String and &str when handling text, since Rust strings are UTF-8 by default and don't require null terminators. Use Result<T, E> when a function can fail in a recoverable way, like file I/O or network calls. Use Option<T> when a value might legitimately be absent, replacing C's NULL pointers. Use std::thread::spawn when you need parallel execution, passing owned data via move closures instead of raw pointers. Use Rc<T> or Arc<T> when multiple parts of your program need to share ownership of the same data. Use unsafe only when you must interface with C libraries, implement a custom allocator, or optimize a measured bottleneck that safe abstractions can't handle.
Map your C patterns to these Rust equivalents before you write a single line of code. The compiler will thank you.