unsafe and Raw Pointers
A city has a building inspector who checks every plan before a single shovel goes in the ground. The inspector reads the blueprint, counts the steel, walks the lot, and stamps approval. Most of the time the system works — buildings stand, nobody falls through a floor. Then a contractor needs to weld a beam the inspector has never seen before, on a Sunday, with a torch the rulebook does not cover. The city does not ban the work. It issues a special permit. The contractor signs their name, takes full responsibility, and does the job inside a fenced-off section of the lot. Everywhere else the inspector still rules. That fenced section is unsafe in Rust, and the raw pointer is the scribbled street address the contractor uses to tell their crew where to dig.

The fence exists because the alternative was every building in town being unsafe by default. C was born at Bell Labs in 1972 when Dennis Ritchie needed a language to rewrite the Unix kernel, and Ritchie's design decision was to hand every programmer the keys to every street address in the city. A pointer in C is a number. You can add to it, subtract from it, dereference it, and the compiler will smile while you walk off a cliff. For sixteen years that felt like power. Then in November 1988 a Cornell grad student named Robert Morris wrote a worm that exploited a buffer overrun in the fingerd daemon — it wrote past the end of an array and overwrote the return address on the stack — and ten percent of the internet went dark in one night. Heartbleed in 2014 did the same trick with OpenSSL. Half the web's secret keys leaked because one C function read past the end of a buffer.
When Graydon Hoare started Rust at Mozilla in 2010, the team's first principle was that those two bugs should be statically impossible. Out went raw pointers as the default. In came the borrow checker — the inspector — which refuses to compile any program where a pointer could outlive its data or two writers could touch the same memory. The cost is that some real, useful work cannot be expressed in safe Rust. Writing a linked list. Talking to hardware over a memory-mapped register. Building a Vec from scratch. For those cases the Rust team kept the C-style power but locked it behind a keyword. The keyword is unsafe, and every line of code that uses a raw pointer must sit inside an unsafe { ... } block. You signed the permit. You took the responsibility.

A raw pointer is not a different kind of address than a reference. It is the same address with the inspector's tag torn off. Here is what happens when you take a borrowed reference and convert it down to the raw form. The borrow checker watches the safe one. The raw one is just bytes — a number sitting in a register, eight bytes wide on a sixty-four-bit machine, no idea what it points at.
fn peek_raw() {
let value: i32 = 42;
let safe_ref: &i32 = &value;
let raw: *const i32 = &value as *const i32;
println!("value at the desk: {value}");
println!("safe reference (borrow checker watches it): &value -> {safe_ref}");
println!("raw pointer (just a number, no checks): *const i32");
println!(" pretend its address is 0x7ffeefbff5a8");
println!(" size of the pointer itself: {} bytes", size_of_val(&raw));
println!(" size of the thing it points to: {} bytes", size_of::<i32>());
}Real memory addresses change every time a program runs because the operating system shuffles where it puts your data — a defense called address space layout randomization. So the print above uses an illustrative address rather than the live one. The point is the shape, not the digit. A raw pointer is the size of a machine word. The thing it points at can be any size. Holding the address tells you nothing about whether the thing is still alive or whether someone else is writing to it at the same instant.
Now build something the borrow checker would never let you build in safe code — a vector from raw allocation. The standard library's Vec<T> is implemented this way internally. Aaron Turon and Niko Matsakis wrote the original version in 2013 and 2014, and every Rust program you have ever run leans on it. Underneath the safe API, Vec is a struct with three fields — a pointer to a block of dirt, a number that says how much dirt was reserved, and a number that says how much of it has been built on. The vocabulary is capacity for the reservation and length for what is occupied. The trick is that you reserve more dirt than you need so that adding a new resident does not always require a new lot. When the residents fill the reservation, you double it, copy everyone over, and free the old slab.
struct MyVec {
ptr: NonNull<i32>,
capacity: usize,
length: usize,
}
impl MyVec {
fn new() -> Self {
MyVec {
ptr: NonNull::dangling(),
capacity: 0,
length: 0,
}
}
fn push(&mut self, value: i32) {
if self.length == self.capacity {
self.grow();
}
unsafe {
let slot = self.ptr.as_ptr().add(self.length);
slot.write(value);
}
self.length += 1;
}
fn grow(&mut self) {
let new_capacity = if self.capacity == 0 {
2
} else {
self.capacity * 2
};
let new_layout = Layout::array::<i32>(new_capacity).expect("layout fits");
let new_ptr = unsafe { alloc(new_layout) } as *mut i32;
let new_ptr = NonNull::new(new_ptr).expect("allocator returned a pointer");
if self.capacity != 0 {
unsafe {
let old_layout = Layout::array::<i32>(self.capacity).expect("old layout");
std::ptr::copy_nonoverlapping(self.ptr.as_ptr(), new_ptr.as_ptr(), self.length);
dealloc(self.ptr.as_ptr() as *mut u8, old_layout);
}
}
self.ptr = new_ptr;
self.capacity = new_capacity;
}
}
impl Drop for MyVec {
fn drop(&mut self) {
if self.capacity == 0 {
return;
}
let layout = Layout::array::<i32>(self.capacity).expect("layout for drop");
unsafe {
dealloc(self.ptr.as_ptr() as *mut u8, layout);
}
}
}Three things in that block require the foreman's permit. The call to alloc asks the operating system for a fresh slab of bytes and hands back a raw pointer to the first byte — alloc is unsafe because the caller promises to free what they take, and the compiler cannot verify the promise. The call to ptr.add(self.length) does pointer arithmetic — it walks length slots past the start of the slab to find the next empty cell. add is unsafe because nothing stops you from walking past the end of the allocation into a neighbor's lot. The call to slot.write(value) stores the value into that cell. write is unsafe because raw pointers do not check that the cell is actually inside the allocation you own.

The renderer below prints the slab as a tape. Each cell is a reserved slot. Built slots show their value. Empty slots show a dot. The header counts how much was reserved, how much is used, and how much slack is left.
fn render_tape(label: &str, v: &MyVec) {
let mut cells = String::from("[");
for i in 0..v.capacity {
if i > 0 {
cells.push(' ');
}
if i < v.length {
let used = unsafe { v.ptr.as_ptr().add(i).read() };
cells.push_str(&format!("{used:>3}"));
} else {
cells.push_str(" .");
}
}
cells.push(']');
let free = v.capacity - v.length;
println!(
"{label:<14} alloc={cap} used={len} free={free} {cells}",
cap = v.capacity,
len = v.length,
);
}fn build_myvec() {
println!("-- memory tape: alloc=capacity, used=length, free=slack --");
let mut v = MyVec::new();
render_tape("start", &v);
v.push(10);
render_tape("push 10", &v);
v.push(20);
render_tape("push 20", &v);
v.push(30);
render_tape("push 30", &v);
v.push(40);
render_tape("push 40", &v);
v.push(50);
render_tape("push 50", &v);
println!("MyVec leaves scope; Drop runs and frees the slab");
}Run it and the tape grows in a pattern you would not see from println!("{:?}", vec).
value at the desk: 42
safe reference (borrow checker watches it): &value -> 42
raw pointer (just a number, no checks): *const i32
pretend its address is 0x7ffeefbff5a8
size of the pointer itself: 8 bytes
size of the thing it points to: 4 bytes
-- memory tape: alloc=capacity, used=length, free=slack --
start alloc=0 used=0 free=0 []
push 10 alloc=2 used=1 free=1 [ 10 .]
push 20 alloc=2 used=2 free=0 [ 10 20]
push 30 alloc=4 used=3 free=1 [ 10 20 30 .]
push 40 alloc=4 used=4 free=0 [ 10 20 30 40]
push 50 alloc=8 used=5 free=3 [ 10 20 30 40 50 . . .]
MyVec leaves scope; Drop runs and frees the slabRead the alloc column down the page. It jumps from zero to two on the first push, sits at two while the second push fills the last cell, doubles to four on the third push, sits there for the fourth, then doubles to eight on the fifth. Every doubling is an alloc of a bigger slab, a memcpy of the existing values, and a dealloc of the old slab — the cost of growing a Vec in two-step jumps instead of one cell at a time. Without that doubling, every push would mean a fresh allocation and an O(n) copy, and a million pushes would do a million copies. With doubling, a million pushes does roughly twenty copies. The amortized cost per push is a constant. This is why Vec is the workhorse of the Rust standard library, and why the implementers were willing to wade into unsafe to write it.

One question you should have — what happens when the MyVec goes out of scope at the end of build_myvec? The Drop impl runs. It calls dealloc on the slab the vector owns, returns the bytes to the operating system, and the next program that asks for a slab of that size might be handed the same physical RAM. The inspector cannot prove that Drop runs correctly. The programmer signed the permit and promised it would. This is the entire shape of unsafe in Rust — a small fenced lot where you do the dangerous work, wrap a safe API around it, and let the rest of the program never see the torch.
The next bottleneck is that even with a Vec that grows in amortized constant time, the program above crashes the moment a single alloc call fails — the next lesson is about turning that crash into a value the caller can read and respond to.