Creating Pointers: Holding Null, Dangling, and Misaligned Values Is Legal
In safe Rust, a reference is a promise: it is never null, never misaligned, always points at a live value of the right type, and obeys the aliasing rules. A raw pointer — *const T or *mut T — makes none of those promises, and the compiler does not ask for them when you create one. Creating a raw pointer is always safe, which is exactly why you can do it in code that never contains an unsafe block:
let null: *const u8 = std::ptr::null(); // null is representable again
let fake = 0xdead_beef as *const u32; // points at nothing in this process
let dangle: *const u8 = std::ptr::NonNull::dangling().as_ptr(); // aligned but unoccupied
let v = vec![10, 20, 30];
let valid = v.as_ptr(); // real, initialized, 12 bytes long
drop(v); // allocation freed
// `valid` now dangles — and no error is reported, here or anywhere
That last line is the mental shift. Move a &[i32] past the drop of its Vec and the borrow checker stops you at the source. Move a *const i32 past the same drop and the compiler says nothing, because raw pointers do not participate in ownership: they are integers with a type attached, copied freely, never borrowed, never dropped, and never checked.
The two flavors differ in one respect only — intent:
*const T |
*mut T |
&T |
&mut T |
|
|---|---|---|---|---|
| May be null / dangling | yes | yes | no | no |
| Dereference | unsafe |
unsafe |
safe | safe |
| Copy the pointer freely | yes | yes | n/a (reborrow) | n/a (reborrow) |
| Promise to readers | "don't write through me" | nothing | shared, stable | exclusive, stable |
*const T means I promise not to write through this, not the memory is immutable — a *const Cell<u8> can still be mutated through another handle. Conversely *mut T carries no exclusivity grant. Casting between them is asymmetrical on purpose: *mut T to *const T is free, while *const T to *mut T requires an unsafe block because you are asserting that no other writer exists.
Casts from references are equally one-directional, and this table is the FFI boundary in miniature:
| Conversion | Safe? | What you owe |
|---|---|---|
&T → *const T |
safe | nothing yet |
&mut T → *mut T |
safe | nothing yet |
*mut T → *const T |
safe | nothing |
*const T → &T |
unsafe | non-null, aligned, live, valid T, shared aliasing ok |
*mut T → &mut T |
unsafe | all of the above plus exclusive access for the reference's whole lifetime |
Pointer arithmetic adds one more rule that surprises people: offsets must stay within the same allocation (plus one past the end). ptr.add(n) is unsafe because the compiler cannot see your bounds; a null or out-of-bounds add is immediate UB. Casting through integers (p as usize + 8) as *const T sidesteps the type system but also throws away the allocation provenance the compiler and hardware use to reason about the pointer — the modern replacement is p.addr() and p.with_addr(new_addr), which keep the provenance intact.
Why do any of this instead of using references? Because some code must transport pointers before it can prove anything: FFI returns one, Vec hands back as_mut_ptr() so you can fill memory without triggering aliasing checks, and intrusive data structures store addresses of their own nodes. Creation is cheap and safe; the reckoning comes at dereference. That reckoning — the checklist you must satisfy before a single read — is the next chapter, and the boundary it protects is where FFI with C Libraries begins. If the four-powers framing of The unsafe Keyword is still fuzzy, reread it now: every dereference here spends one of those powers.
The Validity Checklist Before a Single Dereference
Dereferencing is where a raw pointer becomes undefined behavior or becomes data. Between the two stands a list of six conditions, all of which must hold at the moment of the read or write:
- Non-null — null is not a valid address for any type.
- Aligned — the address must be a multiple of
align_of::<T>(). - Initialized — every byte of the
Tyou are materializing must be initialized, padding included where the type cares. - In bounds — the address must lie inside a live allocation, at most one past its end.
- Live — the allocation must not have been freed or moved-from.
- Correctly aliased — for writes, no live reference may be observing that memory; for reads, no live
&mutmay be observing it.
Miss one and the program is undefined: not a panic, not a wrong answer — the optimizer is free to assume you never did it and transform your code accordingly. Three violations in their cheapest form:
// 1. null — dereference of a null pointer
let p: *const i32 = std::ptr::null();
let _v = unsafe { *p }; // UB
// 2. misaligned — u32 load from an address ≡ 1 (mod 4)
let buf = [0u8; 16];
let q = unsafe { buf.as_ptr().add(1) } as *const u32;
let _w = unsafe { *q }; // UB, even though all bytes exist
// 3. uninitialized — reading memory that was never written
let u: i32 = unsafe { std::mem::MaybeUninit::uninit().assume_init() }; // UB
Alignment deserves its own reminder because it fails quietly on x86 and loudly on ARM: a u64 loaded from an address that is 4 mod 8 is undefined behavior even though every byte is present. read_unaligned and write_unaligned exist for buffers you do not control — they cost a copy on some targets, so the fix belongs at the source: a #[repr(align(8))] field, or an allocator that already hands you aligned blocks.
The vocabulary for doing it right matters as much as the failure modes. Dereferencing a Copy type copies it; dereferencing anything else moves out of the allocation, which is why ptr.read() and ptr.write() are the preferred spelling — they make the ownership transfer explicit:
let mut v: Vec<i32> = Vec::with_capacity(4);
v.extend_from_slice(&[1, 2, 3]); // len 3, capacity 4
let p: *mut i32 = v.as_mut_ptr();
unsafe {
std::ptr::write(p.add(3), 4); // writes into spare capacity — no Vec growth, no aliasing
v.set_len(4); // promises the 4th slot is now initialized
let first = std::ptr::read(p); // bitwise copy out of the buffer
}
write never runs a destructor on the old contents (there are none in spare capacity), read leaves the source bytes intact — so if the type owns heap data, you now have two copies of one allocation and one of them must be forgotten (std::mem::forget) or you double-free. The compiler does not track any of this; the ownership bookkeeping that the type system did for references becomes a comment you maintain by hand.
Two more operations deserve explicit caution. p.as_ref() on a *const T returns Option<&'a T> where you choose 'a — an unbounded lifetime that can outlive the data, so it is the classic way to launder a dangling pointer into a well-typed reference. And reading through p is only sound if condition 6 holds, which you usually cannot check locally: a &mut formed elsewhere in the program can invalidate your pointer under the aliasing model even though nothing in your line of sight looks wrong.
That last condition is the hard one, and it deserves its own treatment — which is what the next chapter, and the aliasing half of Common Unsoundness Patterns, are for.
Aliasing by Hand: Who Else Is Looking While You Write?
Ownership gave you aliasing rules for free: many &T or one &mut T, enforced at compile time. Raw pointers opt out of that enforcement, so you must re-derive it yourself — and the derivation is where unsafe code goes wrong most often, because the failure is invisible in the function you are reading.
let mut data = vec![1, 2, 3];
let p = data.as_mut_ptr();
unsafe {
let a = &mut *p; // &mut to element 0
let b = &mut *p; // second &mut to the same place — UB while both are live
*a = 4;
println!("{b}");
}
Both references were produced from a pointer you legally held, yet forming the second while the first is live is undefined behavior. The compiler did not lose track of anything: it never tracked raw pointers, so it never saw the conflict. The same code passes cargo build and may pass a thousand test runs; Miri flags it on the first one. Two live &mut to overlapping memory is UB whether or not you ever dereference them, because creating the reference asserts exclusivity from that moment.
Mutating through a raw pointer while any & or &mut to that memory exists is the mirror-image violation. The sound escape hatch is UnsafeCell — the one type the language blesses for mutating through shared access — which is what Cell, RefCell, and interior mutability generally are built from. If your type hands out &self while allowing writes, the mutation must run through an UnsafeCell, or the references you handed out were lies.
Then there is the handoff, which is how these pointers leave your program:
fn checksum(bytes: &[u8]) -> u32 {
// & -> *const is free; the * -> & return trip is where the contract lives
let (ptr, len) = (bytes.as_ptr(), bytes.len());
let mut sum: u32 = 0;
unsafe {
for i in 0..len {
sum = sum.wrapping_add(*ptr.add(i) as u32); // one unsafe deref, in-bounds by loop
}
}
sum
}
When the pointer crosses a language boundary the obligation crosses with it. C code has no idea your &[u8] was backed by a Vec that some parallel task might grow; it will keep the address you passed for as long as it likes. Sound handoffs therefore either finish inside the call — the callee reads everything it needs before returning — or transfer an ownership token: a malloc'd block C is obliged to free, or a handle with an explicit destroy function. "It worked in the demo" is the classic epilogue of a pointer that C outlived by one frame.
Rules for handing a pointer to C code — the subject of the next topic — are the same rules applied one step earlier:
- The allocation must outlive the call, and outlive any copy C stashes for later. Passing
v.as_ptr()and then lettingvgrow reallocates the buffer, invalidating the pointer C still holds. - Forming a reference is a commitment.
&mut *ptrpromises exclusive access for the reference's lifetime, not just for the next line. - Never write through a pointer derived from
&T. Derive writes from&mut T,Vec::as_mut_ptr, or a pointer C gave you for writing. - Keep the pointer's provenance inside the allocation it came from — no arithmetic that wanders into a neighboring object.
Aliasing is the invariant the compiler enforced silently for your references and now expects you to name for your pointers. Written down, it is manageable; assumed, it is the single largest source of real-world unsoundness — the exact subject of Common Unsoundness Patterns. Between those two sits FFI with C Libraries, where pointers stop being yours entirely.
Quiz
1. Which statement about creating raw pointers is correct?
2. You save `let p = v.as_ptr();` from a Vec<i32>, then push enough elements to trigger a reallocation, then read through p. What happens?
3. Which of these conversions requires an unsafe block?
4. You call `unsafe { p.as_ref() }` on a `*const T` and store the result in a struct with a long lifetime. What is the specific danger?
Flashcards
Question
Why can *const T be null or dangling when &T cannot?
Click to reveal answer
Answer
A reference is a compiler-checked invariant (never null, aligned, live, valid T, obeys aliasing); a raw pointer is just a typed address with no invariant attached. Creating one — null(), NonNull::dangling(), an integer cast — is always safe because no memory is touched; the obligations attach at dereference.
Question
List the six conditions that must hold when you dereference a raw pointer
Click to reveal answer
Answer
Non-null; aligned to align_of::<T>(); every byte initialized; inside a live allocation (at most one past the end); allocation not freed or moved; correctly aliased — no live &mut while writing, no live shared observer while writing. Missing any one is undefined behavior, not a panic.
Question
ptr.read() versus *ptr — what differs, and what must you remember?
Click to reveal answer
Answer
For Copy types they are equivalent: a bitwise copy. For non-Copy types *ptr moves out of the allocation while ptr.read() leaves the source bytes intact — which means you now own two copies of one heap allocation and must mem::forget one or you double-free. Both require initialized, valid memory.
Question
Which pointer conversions are free, and which cost an unsafe block?
Click to reveal answer
Answer
Free: &T→*const T, &mut T→*mut T, *mut T→*const T (weakening a promise). Unsafe: *const T→*mut T (you assert no other writer exists), *const T→&T (validity + shared aliasing), *mut T→&mut T (validity + exclusivity for the whole lifetime).
Question
What aliasing rule do raw pointers opt out of, and what replaces it?
Click to reveal answer
Answer
They opt out of 'many shared borrows or one exclusive borrow,' so two live &mut formed from the same pointer is UB and writing under a live & is UB — invisible to the compiler because it never tracked the pointers. Replacement: your written invariant — one writer at a time, no writes while any reference observes the memory, UnsafeCell for mutation through &self.
Revision Notes
Key Takeaways
- 1. Creating a raw pointer is safe and unlimited — null, dangling, misaligned, and integer-cast pointers all compile; the unsafe obligation starts at dereference
- 2. A raw pointer does not participate in ownership: it is never borrowed, never checked, and silently dangles when the Vec grows or the owner drops
- 3. Dereference requires six simultaneous conditions (non-null, aligned, initialized, in bounds, live, correctly aliased); missing one is UB, not a panic
- 4. ptr.read/write make the ownership transfer explicit — manual bookkeeping replaces what the type system did for references, including double-free avoidance
- 5. Aliasing by hand is the hardest part: one live &mut per place, no writes under a shared reference, provenance kept inside the original allocation
Interview Tips
- • "What's the difference between *const T and &T?" — *const T is an untracked address that may be null or dangling; &T is a compiler-enforced invariant; converting down is free, converting up is unsafe and re-asserts the invariant for you
- • "When is a raw pointer the right tool?" — when a pointer must exist before its validity can be proven: FFI boundaries, filling Vec spare capacity via as_mut_ptr, intrusive structures storing node addresses
- • "Walk me through checking a dereference" — recite the six conditions, then say which one you can usually only argue rather than check (aliasing), and name Miri as the tool that checks it for you
- • "Why is p.as_ref() dangerous?" — its lifetime is unbounded and chosen by inference, so a dangling pointer comes back as a fully typed &T; combine null-check, provenance, and a lifetime you can actually defend
Cheat Sheet
Raw Pointers — Cheat Sheet
Creation is always safe; dereference is always unsafe.
let n: *const u8 = std::ptr::null(); // null
let d = std::ptr::NonNull::dangling().as_ptr(); // aligned, unoccupied
let p = v.as_ptr(); // valid — until v moves, grows, or drops
let f = 0xdead_beef as *const u32; // points at nothing, compiles anyway
Six conditions required at the moment of dereference:
| # | Condition | Failure mode |
|---|---|---|
| 1 | non-null | UB on *null |
| 2 | aligned to align_of::<T>() |
UB on misaligned load/store |
| 3 | fully initialized | UB reads uninitialized bytes |
| 4 | inside a live allocation | UB past Vec capacity / freed block |
| 5 | allocation still alive | use-after-free |
| 6 | correctly aliased | two live &mut, or write under a & |
Conversion table (the FFI contract):
| From → To | Legal? | You owe |
|---|---|---|
&T → *const T, &mut T → *mut T |
safe | nothing |
*mut T → *const T |
safe | nothing |
*const T → *mut T |
unsafe | exclusive access exists |
*const T → &T |
unsafe | non-null, aligned, live, valid, shared-ok |
*mut T → &mut T |
unsafe | all of the above + exclusive for the lifetime |
Ownership verbs: ptr.read() copies out (source intact — remember mem::forget or double-free), ptr.write() overwrites without dropping, ptr.add(n) needs in-bounds proof, ptr.as_ref() lets you pick the lifetime — the classic dangling launderer.
Aliasing rules to state out loud: one live &mut per place; no write while any & lives; UnsafeCell is the only sound path to mutation through shared access; pointer arithmetic must stay inside its original allocation (prefer addr() / with_addr() over integer casts).