What Is the Difference Between Zero-Sized Types and PhantomData?

Zero-sized types take no memory, while PhantomData is a zero-sized type used to enforce ownership and variance rules for types not actually stored in a struct.

When the compiler thinks you're lying about your data

You're building a custom wrapper around a raw pointer. You want the type signature to be Wrapper<T> so users can write Wrapper<String> or Wrapper<i32>. Inside, you store a *mut T for performance. You don't store a T. You don't store an Option<T>. You store a pointer.

When you compile, Rust assumes T is irrelevant. It sees the generic parameter but finds no field of type T. The compiler marks Wrapper as Send even if T is not. It skips drop checks. It ignores variance rules. You have a silent bug. The compiler thinks you don't care about T, so it lets you do things that would be unsafe if T actually mattered.

You need a way to tell the compiler: "I don't store T, but I promise I behave like I do."

Zero-sized types vs PhantomData

A zero-sized type (ZST) is a type that occupies zero bytes of memory. You can create a million instances, and they take up no space. The unit type () is a ZST. An empty struct is a ZST.

PhantomData<T> is also a ZST. It takes zero bytes. The difference is meaning.

PhantomData is a marker. It carries semantic information about a type parameter without storing the data. It tells the compiler about ownership, variance, and drop behavior.

Think of a ZST as an empty envelope. You can mail it, and it weighs nothing. PhantomData is an empty envelope with a label that says "Registered Mail: Contains Legal Document". The envelope is still empty, but the post office treats it differently. It requires a signature. It has rules. PhantomData adds rules to an otherwise empty type.

Minimal example

Here is the difference in code.

// A plain ZST. No memory, no meaning.
struct Empty;

// PhantomData is also a ZST, but it carries type information.
use std::marker::PhantomData;

struct Wrapper<T> {
    // This field takes 0 bytes.
    // It tells the compiler Wrapper logically owns a T.
    _marker: PhantomData<T>,
}

Empty has no generic parameters. The compiler knows nothing about types associated with it. Wrapper<T> has a generic parameter. The _marker field tells the compiler to treat Wrapper as if it owns a T. The compiler enforces Send, Sync, drop checks, and variance based on T.

Why not just store a dummy value?

You could store Option<T> and keep it None. You could store a T and never use it. Both approaches work, but they waste memory. Storing a value takes space. PhantomData takes zero space.

In Rust, memory layout matters. If you have a struct with many fields, adding a dummy T can increase the size of the struct, cause padding, or break alignment. PhantomData lets you carry type information without touching the memory layout. It is the efficient way to satisfy the compiler's requirements.

Convention aside: PhantomData lives in std::marker, not std::mem. The name comes from "marker traits". You'll see imports like use std::marker::PhantomData; everywhere. Don't search for it in mem. It's a marker, not a memory tool.

What PhantomData actually does

PhantomData affects three things: ownership semantics, drop behavior, and variance.

Ownership and Send/Sync

When you add PhantomData<T>, the compiler assumes your type owns a T. This affects trait bounds.

If T is not Send, your type is not Send. If T is not Sync, your type is not Sync. Without PhantomData, the compiler thinks T is irrelevant and marks your type as Send and Sync regardless of T.

This is critical for wrappers around raw pointers. If you wrap a pointer to thread-local data, you need the wrapper to be !Send. PhantomData<T> enforces that.

use std::marker::PhantomData;

// This wrapper holds a raw pointer but claims ownership of T.
struct RawWrapper<T> {
    ptr: *mut T,
    // SAFETY: PhantomData<T> tells the compiler we own T.
    // This enforces Send/Sync bounds based on T.
    // Without this, RawWrapper would be Send even if T is not.
    _marker: PhantomData<T>,
}

If T is Rc<String>, which is not Send, RawWrapper<Rc<String>> is not Send. The compiler prevents you from sending it across threads. If you remove PhantomData, the compiler allows it, and you get a data race.

Drop checks and derived traits

PhantomData also triggers drop checks. The compiler ensures you don't leak borrows through your type. If you have a raw pointer and PhantomData<T>, the compiler checks that you're not creating a use-after-free scenario by dropping the wrapper in a way that invalidates references.

PhantomData also affects derived traits. If you derive Clone or Copy on a struct, the compiler checks the fields. PhantomData<T> makes the compiler check if T implements Clone or Copy.

use std::marker::PhantomData;

#[derive(Clone)]
struct Wrapper<T> {
    _marker: PhantomData<T>,
}

// This fails to compile if T is not Clone.
// PhantomData<T> forces the Clone bound.
let _w: Wrapper<std::rc::Rc<i32>> = Wrapper { _marker: PhantomData };

Without PhantomData, Wrapper would derive Clone even if T is not Clone. That's a bug. PhantomData stops you.

Variance control

Variance describes how subtyping works with generic types. If Dog is a subtype of Animal, is Wrapper<Dog> a subtype of Wrapper<Animal>?

PhantomData lets you control this.

PhantomData<T> is covariant. Wrapper<Dog> is a subtype of Wrapper<Animal>. This is the default behavior for owned data.

PhantomData<&T> is invariant. Wrapper<Dog> is not a subtype of Wrapper<Animal>. This is required when you hold a reference, because you might write to it.

PhantomData<fn(T) -> T> is contravariant. Wrapper<Animal> is a subtype of Wrapper<Dog>. This is rare, but it happens with function pointers.

If you have a reference inside your type, using PhantomData<T> is a bug. You need PhantomData<&T> to enforce invariance. Using the wrong PhantomData can let the compiler accept code that is actually unsafe.

Counter-intuitive but true: PhantomData is the only way to control variance in Rust. You can't do it with regular fields. If you need invariance, you need PhantomData<&T>.

Realistic example: A drop guard

Drop guards are common in Rust. You create a struct that does something when it's dropped. The struct often doesn't store data, but it needs to carry type information for trait bounds.

use std::marker::PhantomData;

// A guard that ensures a resource is released.
// The resource is managed externally, so we don't store it.
struct DropGuard<T> {
    // We don't store T. We just need the type for bounds.
    _marker: PhantomData<T>,
}

impl<T> DropGuard<T> {
    fn new() -> Self {
        Self { _marker: PhantomData }
    }
}

impl<T> Drop for DropGuard<T> {
    fn drop(&mut self) {
        // Perform cleanup logic here.
        // PhantomData ensures T's drop semantics are respected.
    }
}

Here, DropGuard doesn't store T. It uses PhantomData to satisfy trait bounds and ensure the compiler treats the type correctly. If T has specific requirements, PhantomData enforces them.

Convention aside: The community names PhantomData fields _marker. The underscore suppresses unused warnings. The name marker indicates it's a marker field. You'll see _marker, _phantom, or _pd in the wild. Stick with _marker. It's the standard. Also, PhantomData is often the last field in a struct. This keeps the layout clean and signals that it's a marker, not data.

Pitfalls and compiler errors

Forgetting PhantomData is the most common mistake. You get no compiler error. You get a runtime bug. The compiler thinks your type is Send when it shouldn't be. You send data across threads that can't be sent. The program crashes or corrupts memory.

Using the wrong PhantomData is the second mistake. If you have a reference, use PhantomData<&T>. If you use PhantomData<T>, you get covariance. The compiler might let you substitute types in ways that break safety.

When you add PhantomData, the compiler enforces bounds. You might see E0277 (the trait bound T: Send is not satisfied). This is good. It stops you from making a bug. If you try to send a type that contains PhantomData<T> and T is not Send, the compiler rejects it.

use std::marker::PhantomData;
use std::rc::Rc;

struct Wrapper<T> {
    _marker: PhantomData<T>,
}

// This fails to compile.
// Rc<String> is not Send.
// PhantomData<T> makes Wrapper not Send.
let _wrapper: Wrapper<Rc<String>> = Wrapper { _marker: PhantomData };
// Attempting to send _wrapper across threads triggers E0277.

The error tells you exactly what's wrong. PhantomData is doing its job. It's enforcing the rules you asked for.

Decision matrix

Use a plain ZST like struct Marker; when you need a tag type for pattern matching or trait specialization and the type carries no generic parameters.

Use PhantomData<T> when your struct or enum has a generic parameter T that represents owned data, even if the actual storage is a raw pointer, an index, or an external resource.

Use PhantomData<&T> when your type holds a reference to T and you need the compiler to enforce the lifetime and invariance of that reference.

Use PhantomData<fn(T) -> T> when you need to mark a type parameter as contravariant, which happens in rare cases like function pointer wrappers.

Reach for Option<T> or a dummy value only when you need to store actual data. PhantomData is for semantics, not storage.

Where to go next

PhantomData is a tool for advanced type manipulation. It works closely with traits and dispatch.

Treat PhantomData as a contract. If you lie about what it holds, the compiler will enforce the lie, and you'll pay the price in runtime bugs.