The magic button that breaks things
You're debugging a crash that happens three functions deep. The stack trace points to a helper that looks innocent. It just converts a struct to an array. You used std::mem::transmute. The compiler didn't warn you. The debugger shows garbage values where valid data should be. This is the transmute trap.
transmute feels like a shortcut. It promises to turn any type into any other type instantly. It's also the fastest way to introduce undefined behavior into your codebase. Rust gives you transmute because sometimes you need to lie to the compiler about the type of a value. The compiler doesn't know the layout of a C struct, the bit pattern of a SIMD register, or the validity rules of a hardware register. transmute lets you bridge those gaps. It also removes every safety net. If you're wrong, the program is broken.
What transmute actually does
transmute doesn't copy data. It doesn't convert values. It changes the label the compiler uses for a sequence of bytes.
Imagine a box labeled "Point". Inside are two floating-point numbers. transmute peels off the "Point" label and sticks on "[f64; 2]". The bytes inside the box haven't moved. The compiler now treats those bytes as an array. If the layout matches, everything works. If the layout differs, you've just convinced the compiler to read memory incorrectly. That's undefined behavior.
At runtime, transmute generates no code. It's a zero-cost cast. The CPU sees the same bytes. The only change happens in the compiler's type system. The compiler assumes the destination type's rules apply to the source bytes. If those rules are violated, the compiler may have optimized away checks or generated instructions that crash on invalid inputs.
The contract: size, alignment, validity
Using transmute safely requires satisfying three conditions. The compiler checks only one of them. You must check the other two.
- Size: The source and destination types must have the same size in bytes. The compiler enforces this. If sizes differ, you get a hard error with code E0512 (transmute requires that the source and target have the same size).
- Alignment: The source and destination types must have compatible alignment. The compiler does not check this. If you transmute a type with alignment 1 to a type with alignment 4, you may end up with a value that is only 1-aligned. Dereferencing that value is undefined behavior.
- Validity: The bit pattern of the source must be a valid value for the destination type. The compiler does not check this. If you transmute a
u8with value 5 into abool, you have undefined behavior. The compiler assumesboolis always 0 or 1 and may optimize based on that assumption.
Minimal example
This example shows a safe use of transmute. The types have identical size, identical alignment, and no validity invariants that could be violated.
use std::mem;
/// Converts a Point to a raw array of floats.
/// Only safe because of repr(C) and identical field types.
fn point_to_array(point: Point) -> [f64; 2] {
// SAFETY:
// 1. Point and [f64; 2] have the same size (16 bytes).
// 2. Point and [f64; 2] have the same alignment (8 bytes).
// 3. #[repr(C)] ensures x is first, y is second, matching array index 0 and 1.
// 4. f64 has no validity invariants; any bit pattern is a valid float.
unsafe { mem::transmute(point) }
}
#[repr(C)]
struct Point {
x: f64,
y: f64,
}
fn main() {
let p = Point { x: 1.0, y: 2.0 };
let arr = point_to_array(p);
assert_eq!(arr, [1.0, 2.0]);
}
The #[repr(C)] attribute is critical here. Without it, the compiler can reorder fields for optimization. Point might store y before x. The transmute would swap the values. #[repr(C)] locks the layout to the declaration order.
Convention aside: The community treats transmute as a last resort. If you see transmute in a code review, expect questions. There are almost always safer alternatives. Reach for MaybeUninit, from_bits, or ptr::cast first.
The trap of lifetimes
transmute can erase lifetimes. This is a common source of use-after-free bugs.
use std::mem;
/// WARNING: This function is unsound.
/// It extends the lifetime of a reference beyond the data's validity.
fn extend_lifetime<'a, 'b, T>(r: &'a T) -> &'b T {
// SAFETY: This comment is a lie. The lifetimes are not equivalent.
// The compiler believes the returned reference is valid for 'b.
// If 'b outlives 'a, this is undefined behavior.
unsafe { mem::transmute(r) }
}
The compiler thinks extend_lifetime returns a reference valid for lifetime 'b. It doesn't check if 'b is actually valid. If you call this with a short lifetime and a long lifetime, the borrow checker believes the reference lives forever. It doesn't. Accessing it later is a use-after-free.
The borrow checker can't save you from transmute. You are the borrow checker now. If you transmute lifetimes, you must prove that the data actually lives as long as the new lifetime claims. If you can't prove it, don't do it.
Validity invariants: why bool is not u8
Every type in Rust has validity invariants. These are rules about which bit patterns are allowed. The compiler assumes these rules hold. Violating them is undefined behavior.
boolmust be 0 or 1.charmust be a valid Unicode scalar value (not a surrogate).- References must point to valid, aligned memory.
f64has no validity invariants. Any bit pattern is a valid float.
Transmuting u8 to bool is dangerous. If the u8 is 5, the resulting bool is invalid. The compiler may optimize away checks for bool values, assuming they are always 0 or 1. This can cause silent corruption or crashes.
use std::mem;
/// Converts a u8 to a bool.
/// UNSAFE: u8 values other than 0 and 1 create invalid bools.
fn u8_to_bool_unsafe(value: u8) -> bool {
// SAFETY: This is only safe if value is 0 or 1.
// If value is 5, this is undefined behavior.
unsafe { mem::transmute(value) }
}
Use value != 0 instead. It's safe, fast, and clear. transmute adds no value here. It only adds risk.
Realistic example: Protocol buffers
Sometimes transmute is the right tool. You're reading a binary protocol buffer. The data arrives as a slice of bytes. You need to interpret it as a struct. You can't use serde because the format is custom. You can't use from_bytes because the struct has padding.
use std::mem;
/// Reinterprets a 4-byte slice as a packed header.
/// Used for parsing raw network packets.
fn parse_header(bytes: [u8; 4]) -> PacketHeader {
// SAFETY:
// 1. [u8; 4] and PacketHeader have the same size (4 bytes).
// 2. [u8; 4] and PacketHeader have the same alignment (1 byte).
// 3. #[repr(C, packed)] ensures no padding and fixed field order.
// 4. u8 and u16 have no validity invariants; any bit pattern is valid.
unsafe { mem::transmute(bytes) }
}
#[repr(C, packed)]
struct PacketHeader {
id: u16,
flags: u8,
version: u8,
}
The #[repr(C, packed)] attribute removes padding. The struct is exactly 4 bytes. The alignment is 1. The transmute is safe because the size matches, the alignment matches, and the fields have no validity invariants.
Note the alignment mismatch risk. PacketHeader has alignment 1. If you transmute this to a type with alignment 4, you get undefined behavior. The parse_header function returns PacketHeader, which has alignment 1. That's safe. If you returned a u32, you'd be returning a value with alignment 1 that the compiler thinks has alignment 4. That's UB.
Pitfalls
- Field order: Without
#[repr(C)], field order is undefined.struct A { x: u8, y: u8 }might storeyfirst. Transmuting to[u8; 2]swaps the values. - Padding: Structs may have padding bytes. Transmuting to a type without padding interprets padding as data. The values are unpredictable.
- Endianness:
transmutedoesn't swap bytes. If you transmute au32to[u8; 4], the byte order depends on the platform. Useto_le_bytesorto_be_bytesfor portable code. - Unions: Rust supports
unionfor type punning. Unions are safer thantransmutebecause they requireunsafeto read and make the intent explicit. Prefer unions when you need to reinterpret bits.
The borrow checker can't save you from transmute. You are the borrow checker now. If you can't write a // SAFETY comment that lists every invariant, you don't have one.
Decision: when to use this vs alternatives
Use f64::to_bits and f64::from_bits when you need to convert between a float and its integer representation. These methods handle endianness and validity safely.
Use MaybeUninit::assume_init when you are initializing memory in place or transmuting a MaybeUninit<T> to T. This is the safe way to finish initialization.
Use ptr::cast when you need to cast between raw pointer types. Raw pointers allow unsafe casts without the full weight of transmute.
Use std::mem::transmute only when you are reinterpreting bits between types that have no safe conversion path, you have verified identical size and alignment, and you have proven that the bit pattern satisfies the destination type's validity invariants.
Reach for the safe API first. transmute is the emergency brake, not the steering wheel.