Skip to content

Rust Unsafe

Every guarantee this course has talked about — no dangling references, no data races, no out-of-bounds access — is enforced by the compiler. unsafe is how you tell the compiler “let me do something you can’t verify is safe; I’m taking responsibility for it instead.”

A common misconception is that unsafe turns off Rust’s safety checks entirely. It doesn’t. Inside an unsafe block, you can do exactly five extra things that are otherwise forbidden:

  1. Dereference a raw pointer
  2. Call an unsafe function
  3. Access or modify a mutable static variable
  4. Implement an unsafe trait
  5. Access fields of a union

Everything else — the borrowing rules, move semantics, type checking — still fully applies inside unsafe code. It’s a scalpel for five specific operations, not an off switch.

Rust’s normal references (& and &mut) always point to valid, properly aligned data — that’s part of what the compiler guarantees. A raw pointer (*const T or *mut T) drops that guarantee, which is why reading one requires unsafe:

fn main() {
let value = 5;
let pointer = &value as *const i32;
unsafe {
println!("{}", *pointer);
}
}

Output:

5
▶ Try it Yourself

You can create a raw pointer in safe code — it’s only dereferencing it (reading the value it points to) that needs unsafe, since the compiler can no longer verify the pointer is valid.

Some functions are themselves marked unsafe because they have requirements the compiler can’t check for you — calling them is your promise that you’ve verified those requirements yourself:

unsafe fn dangerous() {
println!("this function trusts you to have checked its requirements");
}
fn main() {
unsafe {
dangerous();
}
}

Output:

this function trusts you to have checked its requirements
▶ Try it Yourself

The standard library has real unsafe functions you can call. One is get_unchecked on slices. It works like numbers[i], but skips the bounds check. If i is out of range, it doesn’t panic. It reads whatever memory happens to be there, which is called undefined behavior: your program might crash, print garbage, or seem fine until it doesn’t.

So the rule for calling it is “only with an index you know is valid”. The usual pattern is to check that rule yourself, inside a normal, safe function:

fn first_or_zero(numbers: &[i32]) -> i32 {
if numbers.is_empty() {
return 0;
}
// SAFETY: the slice is not empty, so index 0 is in bounds.
unsafe { *numbers.get_unchecked(0) }
}
fn main() {
println!("{}", first_or_zero(&[7, 8, 9]));
println!("{}", first_or_zero(&[]));
}

Output:

7
0
▶ Try it Yourself

Look at what the caller sees: fn first_or_zero(numbers: &[i32]) -> i32. There’s no unsafe in the signature, and no way to call it wrongly, because the function checks the rule before it ever reaches the unsafe block. The unsafe stays small and hidden inside.

Two habits that experienced Rust developers follow:

  • Write a // SAFETY: comment above every unsafe block, saying why the rules are met. It’s for the next person reading the code, and for future you.
  • Keep the unsafe block as small as you can, so there’s less code to double-check.

To be honest, you wouldn’t write this exact function in real code: numbers.first() does the same job safely, and the compiler is very good at removing bounds checks it can prove are not needed. But the pattern (check the rules, then call unsafe inside a safe function) is exactly how Vec, String and friends are built. The FFI page uses the same trick to wrap C functions.

If Rust is all about safety, why allow this at all? Because some things are genuinely impossible to prove safe at compile time, even though they’re perfectly safe in practice — talking directly to hardware, calling into a C library (see FFI), or building certain high-performance data structures. unsafe lets the standard library (and, rarely, your own code) implement these low-level pieces, while everything built on top of them stays in ordinary, fully-checked safe Rust.

Writing unsafe doesn’t mean your code is now buggy — it means the compiler can no longer catch certain classes of mistakes for you in that specific block, and it’s on you to uphold the same guarantees by hand. Most experienced Rust developers write very little unsafe code directly; it tends to live in small, carefully-reviewed pockets at the bottom of a library, wrapped in a safe API for everyone else to use.

Make a let mut score = 10;, create a *mut i32 raw pointer to it, and use the pointer to add 5. Then print score. Hint: create the pointer with &mut score as *mut i32, and write *pointer += 5 inside an unsafe block.

Show solution
fn main() {
let mut score = 10;
let pointer = &mut score as *mut i32; // creating it is safe
unsafe {
*pointer += 5; // writing through it is not
}
println!("score = {score}");
}

Output:

score = 15
▶ Try it Yourself

This is safe in practice because score is still alive and nothing else is using it while we write. Keeping those promises is your job once you use unsafe.

Write fn get_or(numbers: &[i32], index: usize, default: i32) -> i32. It returns the item at index, or default if the index is out of range. Use get_unchecked for the read, and make sure the function can never be called in a way that breaks its rule. Hint: compare index < numbers.len() first, and only call get_unchecked when that’s true.

Show solution
fn get_or(numbers: &[i32], index: usize, default: i32) -> i32 {
if index < numbers.len() {
// SAFETY: we just checked that index is in bounds.
unsafe { *numbers.get_unchecked(index) }
} else {
default
}
}
fn main() {
let numbers = [10, 20, 30];
println!("{}", get_or(&numbers, 1, -1));
println!("{}", get_or(&numbers, 5, -1));
}

Output:

20
-1
▶ Try it Yourself

Index 5 never reaches the unsafe block, so it’s handled safely. For comparison, the fully safe version is numbers.get(index).copied().unwrap_or(default), and that’s what you’d normally write.

  • unsafe unlocks exactly five operations (mainly raw pointers and calling unsafe functions) — everything else is still fully checked.
  • A raw pointer (*const T / *mut T) can be created in safe code; dereferencing it requires unsafe.
  • Wrap unsafe in a small, safe function that checks the rules first, and explain why it’s sound in a // SAFETY: comment.
  • unsafe exists so low-level code (hardware access, C interop) can be built, while code using it stays safe.
  • As a beginner, you’ll rarely need to write it yourself — reach for a safe abstraction first.

Quick check

1. What does an unsafe block actually unlock?

2. Can you create a raw pointer in safe code?

3. As a beginner, how often should you expect to write unsafe yourself?

4. Does writing unsafe code mean your program now has bugs?

Score: 0 / 4