
When writing unsafe Rust, there’s a problem that seems simple but can cause production disasters:
You store a reference from one collection into another field, thinking “as long as I don’t modify the collection, the address is stable.”
For Vec, this is true. For HashMap, the documentation makes no such promise.
This article explains why—from memory layout to how Miri detects this kind of UB.
Vec’s Memory Layout: Why It Has Stable Address Guarantees
Let’s look at Vec’s actual structure. The simplified definition from the standard library looks like this:
pub struct Vec<T> {
buf: RawVec<T>,
len: usize,
}
struct RawVec<T> {
ptr: Unique<T>,
cap: usize,
alloc: Global,
}
ptr points to a contiguous block of heap memory, len is how much is currently used, and cap is the total capacity. Key point: The Vec struct itself only has three usize-sized fields; elements are stored on the heap, not inside the Vec.
The Rust standard library documentation in Vec::as_ptr
explicitly states:
“The caller must ensure that the vector outlives the pointer this function returns, or else it will end up pointing to garbage.”
More importantly: Vec guarantees it will never use “small vector optimization” (SSO). This means even if you create an empty Vec, it won’t inline elements on the stack. Only when capacity is 0 is the pointer dangling—but once heap memory is allocated, that memory’s address is stable until reallocation is triggered.
What triggers reallocation? Only these situations:
push/insertcauseslen == cap, requiring growth- Manually calling
shrink_to_fit - Methods that consume Vec like
into_raw_parts
As long as you don’t do these, the heap address is rock solid.
HashMap’s Real Structure: Why There’s No Equivalent Guarantee
Now let’s look at HashMap. The standard library uses the hashbrown
implementation, with the core structure being RawTable:
struct RawTable<T> {
ctrl: NonNull<u8>, // Control byte array (describes each slot's state)
bucket_mask: usize, // Array size - 1, used for hash masking
items: usize, // Current element count
growth_left: usize, // How many more elements can be inserted before growth
}
Note: HashMap has no explicit pointer to the heap. ctrl points to a contiguous memory region that contains both control bytes and actual key-value pairs. The overall layout looks roughly like this:
[ctrl0, ctrl1, ..., ctrlN, pad, | K0, V0, K1, V1, ... KN, VN ]
^ ^
ctrl pointer Key-value pairs start here
Key differences from Vec:
- Different growth strategy: Load factor exceeding 0.875 triggers rehash, moving all elements
- Deletion leaves tombstones:
removedoesn’t immediately compact, just marks as deleted - No documented address stability guarantee: The standard library only says
HashMap::with_capacity(n)can hold n elements without reallocation, but doesn’t say element addresses stay the same
Searching through the HashMap
documentation, you’ll find no guarantee similar to Vec’s “element storage location” promise.
Code That Looks Legal but Is Actually UB
Here’s a concrete example. Suppose you want to implement an indexed string parser:
use std::collections::HashMap;
struct Parser<'a> {
data: HashMap<String, String>,
current: Option<&'a str>, // Points to a value in data
}
impl<'a> Parser<'a> {
fn new() -> Self {
Parser {
data: HashMap::new(),
current: None,
}
}
fn set_key(&mut self, key: &str) {
// Assume key definitely exists
self.current = self.data.get(key).map(|v| v.as_str());
}
}
This code can’t even be written in safe Rust—the borrow checker will stop you. But if you bypass it with unsafe:
use std::collections::HashMap;
struct BadParser {
data: HashMap<String, String>,
current: *const str, // Raw pointer, bypassing borrow check
}
impl BadParser {
fn new() -> Self {
BadParser {
data: HashMap::new(),
current: std::ptr::null(),
}
}
unsafe fn set_key(&mut self, key: &str) {
if let Some(v) = self.data.get(key) {
self.current = v.as_str(); // Store pointer to HashMap value
}
}
unsafe fn get(&self) -> Option<&str> {
if self.current.is_null() {
None
} else {
Some(&*self.current) // Dereference
}
}
}
The problem: if you later insert into data, it may trigger rehash, moving all values to new addresses. At this point, current becomes a dangling pointer.
Using Miri to Detect This UB

Miri is Rust’s undefined behavior detection tool. Let’s write an example that triggers the problem:
use std::collections::HashMap;
fn main() {
let mut map: HashMap<u32, String> = HashMap::with_capacity(2);
map.insert(1, "hello".to_string());
// Get raw pointer to value
let ptr: *const String = map.get(&1).unwrap();
// Trigger rehash
map.insert(2, "world".to_string());
map.insert(3, "foo".to_string()); // Triggers growth
// ptr is now dangling!
unsafe {
let _val = &*ptr; // UB!
}
}
Run cargo miri run:
$ cargo miri run
error: Undefined Behavior: pointer to alloc1403 is out-of-bounds
--> src/main.rs:15:20
|
15 | let _val = &*ptr;
| ^^^^^ pointer to alloc1403 is out-of-bounds
|
= help: this indicates a bug in the program: it performed an invalid operation
Miri explicitly tells you: dereferencing a dangling pointer is UB.
Let’s verify the same problem with Vec:
fn main() {
let mut vec = Vec::with_capacity(2);
vec.push("hello".to_string());
let ptr: *const String = &vec[0];
vec.push("world".to_string());
vec.push("foo".to_string()); // Triggers growth, ptr invalid
unsafe {
let _val = &*ptr; // Same UB
}
}
Miri will also error. The difference: Vec’s documentation explicitly tells you when reallocation happens, HashMap doesn’t.
Can Pin Solve This?
Some say: use Pin!
Pin was introduced in Rust 1.32 to mark “this value won’t move.” But it’s not magic:
use std::pin::Pin;
use std::marker::PhantomPinned;
struct SelfRef {
data: String,
ptr: *const u8,
_pin: PhantomPinned, // Mark as !Unpin
}
impl SelfRef {
fn new(text: &str) -> Pin<Box<Self>> {
let mut boxed = Box::new(SelfRef {
data: text.to_string(),
ptr: std::ptr::null(),
_pin: PhantomPinned,
});
let ptr = boxed.data.as_ptr();
boxed.ptr = ptr;
Box::into_pin(boxed)
}
}
Note: Here Pin is on the Box containing SelfRef, not the String itself. Box guarantees heap address stability, and String’s heap buffer address is also stable, so ptr pointing inside data is stable.
But if you Pin HashMap:
let map = HashMap::new();
let pinned = Pin::new(&map); // Completely useless!
Pin can only prevent the HashMap struct itself from being moved, it can’t control HashMap’s internal rehash moving elements. Pin has no magic; it can’t change collection internals.
Correct Approaches: Stable Address Solutions
If you really need self-referential structures, here are some approaches:
Solution 1: Box Wrapping
use std::collections::HashMap;
struct StableIndex<'a> {
// Box guarantees heap address stability
data: HashMap<String, Box<String>>,
current: Option<&'a str>,
}
HashMap moves Box pointers (usize size), but Box points to stable heap memory.
Solution 2: Use Dedicated Arena Crates
use generational_arena::{Arena, Index};
struct WithArena {
data: Arena<String>,
current: Option<Index>, // Generational index, not pointer
}
Arenas guarantee element address stability until deletion, and Index has generation to detect use-after-free.
Solution 3: owning_ref or self_cell
use self_cell::self_cell;
self_cell! {
struct Parser {
owner: String,
#[covariant]
dependent: str, // Points inside owner
}
}
self_cell uses unsafe to encapsulate self-reference logic while providing a safe API.
Summary
| Collection | Address Stability Guarantee | Reallocation Trigger | Safe for Self-Reference? |
|---|---|---|---|
| Vec | Documented promise | push beyond cap | Yes (if stable no growth) |
| String | Documented promise | push_str beyond cap | Yes |
| HashMap | No promise | load factor > 0.875 | No |
| BTreeMap | No promise | node split/merge | No |
Key logic: In safe Rust, you couldn’t do this dangerous operation anyway. But when writing unsafe, if it’s not in the documentation, it’s not a guarantee. Today’s implementation detail might change tomorrow.
Always run Miri before shipping unsafe Rust. It can save you.
Want to learn more Rust internals? Follow the “Full Stack Summit - Mengshou Programming” WeChat official account for weekly updates.
Also check out Mengshou Programming AI Programming Assistant Service to help you use AI programming tools in production.
FAQ
Is storing references in Vec always safe?
It’s safe as long as the reference points to heap data. Rust explicitly guarantees Vec won’t inline elements. But if the reference points to a temporary stack variable, that’s another story.
Will HashMap really use inline storage?
Not currently. But Rust’s stability promise is “if it’s not in the docs, it’s not guaranteed,” so you can’t assume future implementations won’t optimize this way.
Can I use BTreeMap instead of HashMap?
Same logic—BTreeMap also has no documented promise that keys/values don’t overlap with the struct’s address. Choose based on performance needs, not to solve this inline problem.

