Custom Hash and Eq

To use a type as a HashMap key or HashSet member, it must implement Hash and Eq. Derive Macros and Common Traits showed that #[derive(Hash, Eq, PartialEq)] handles this for most types. But the derive isn't always what you want, sometimes only part of a struct should determine identity (an ID field, not a cached timestamp), and that's where hand-implementing comes in. The catch is a contract between the two traits that's easy to violate and produces bugs that are genuinely nasty to debug: keys that vanish from a map they're supposedly in. This tutorial covers that contract, when to hand-write, and how to do it without breaking things.


The Contract: Equal Values Must Hash Equal

There is one inviolable rule linking Hash and Eq: if two values are equal (a == b), they must produce the same hash. A HashMap uses the hash to find the bucket, then Eq to confirm the match; if equal keys hash differently, a lookup goes to the wrong bucket and the entry appears to not exist.

use std::collections::HashMap;

// the derived impls satisfy the contract automatically — hash and eq use the SAME fields
#[derive(Hash, PartialEq, Eq)]
struct UserId(u64);

let mut map = HashMap::new();
map.insert(UserId(42), "Alice");
assert_eq!(map.get(&UserId(42)), Some(&"Alice"));   // found — hash and eq agree

When you #[derive] both, they're computed from the same set of fields, so the contract holds by construction. The danger appears only when you hand-write one of them, or derive one and hand-write the other, and accidentally make them consider different fields. The reverse direction (different values may share a hash) is fine and expected, that's a hash collision, which Eq resolves. Only "equal but different hashes" is a bug.


Hashing a Subset of Fields

The main legitimate reason to hand-implement is when a struct's identity depends on only some of its fields. A cached or derived field, a timestamp, a memoized value, shouldn't affect whether two records are "the same":

use std::hash::{Hash, Hasher};

struct User {
    id: u64,              // identity
    name: String,         // identity
    last_seen: u64,       // NOT identity — a mutable cache, must be ignored
}

impl PartialEq for User {
    fn eq(&self, other: &Self) -> bool {
        self.id == other.id && self.name == other.name   // ignores last_seen
    }
}
impl Eq for User {}

impl Hash for User {
    fn hash<H: Hasher>(&self, state: &mut H) {
        self.id.hash(state);
        self.name.hash(state);                            // hashes the SAME fields eq uses
    }
}

The critical discipline: Hash and Eq must consider the exact same fields. Here both use id and name and both ignore last_seen, so the contract holds, two Users with the same id/name are equal and hash equal regardless of last_seen. Get this out of sync (hash last_seen but don't compare it, or vice versa) and you reintroduce the "equal but different hash" bug.

Gotcha: the failure mode of a broken Hash/Eq contract is uniquely nasty. You insert a key, then get it back with an equal key, and get None, the entry is in the map but unreachable, because the lookup hashes to a different bucket than the insert did. It doesn't panic or error; the map just silently behaves as if the key isn't there, and iterating the map does show it. Any time a HashMap "loses" a key you're certain you inserted, suspect a hand-written Hash and Eq that disagree on which fields matter.


The Derive Trap: Deriving One, Hand-Writing the Other

A subtle version of the same bug: you hand-write PartialEq to ignore a field, but leave #[derive(Hash)] in place (or vice versa). Now they silently disagree.

// BUG: Hash is derived (hashes ALL fields) but Eq ignores last_seen
#[derive(Hash)]                          // hashes id, name, AND last_seen
struct User {
    id: u64,
    name: String,
    last_seen: u64,
}
impl PartialEq for User {
    fn eq(&self, o: &Self) -> bool { self.id == o.id && self.name == o.name }  // ignores last_seen
}
impl Eq for User {}
// two Users equal by eq (same id/name, different last_seen) now hash DIFFERENTLY → broken

The fix is the rule: if you hand-write one of Hash/Eq, hand-write the other to match. Don't mix a derived one with a custom one unless you've verified they consider identical fields. When in doubt, the safest design is to not store non-identity fields in the key type at all, put them in the value, so the key can be cleanly derived.


Newtype Keys: A Cleaner Alternative

Often the better move is to sidestep custom impls entirely by making the key a dedicated newtype (Newtype Pattern) containing only the identity fields, and keeping everything else in the value:

use std::collections::HashMap;

#[derive(Hash, PartialEq, Eq)]           // fully derived — contract holds trivially
struct UserKey { id: u64, name: String }

struct UserData { last_seen: u64, email: String }   // non-identity fields

let mut users: HashMap<UserKey, UserData> = HashMap::new();

Now UserKey derives all three traits from all its fields (they're all identity), so the contract is automatic, and the mutable/cached data lives in UserData where it can change freely without affecting lookup. This is usually cleaner than a hand-written partial Hash/Eq: the type system expresses "these fields are the identity" directly, and there's no contract to accidentally break. Reach for a custom impl only when you can't restructure the key this way.


Deciding How to Implement

SituationDo this
All fields define identity#[derive(Hash, PartialEq, Eq)]
Only some fields define identitynewtype key of just those fields (preferred), or hand-write both
Hand-writing Hash or Eqhand-write the other; use the same fields in each
Non-identity/cached datakeep it in the value, not the key
Key needs &str lookup on a String fieldderive normally; Borrow handles it (Deref, AsRef, Borrow)

The overriding principle: Hash and Eq must agree on exactly which fields constitute identity. Deriving both guarantees it for free; the moment you hand-write either, you take on the obligation to keep them in sync, and the penalty for failing is a silent, hard-to-trace "lost key" bug. Prefer restructuring (a newtype key) over a custom impl whenever you can.


Key Takeaways

  • The Hash/Eq contract: equal values must hash equal. A HashMap finds the bucket by hash then confirms with Eq, so if equal keys hash differently, lookups silently fail.
  • #[derive(Hash, PartialEq, Eq)] satisfies the contract automatically because all three use the same fields. Collisions (different values, same hash) are fine, Eq resolves them; only "equal but different hash" is a bug.
  • Hand-implement when only some fields define identity (ignore caches/timestamps), but Hash and Eq must consider the exact same fields, or you get the "key is in the map but unreachable" bug.
  • Never mix a derived Hash with a hand-written Eq (or vice versa) unless they consider identical fields, the derive hashes all fields and will disagree with a partial Eq.
  • Prefer a newtype key containing only identity fields (with everything else in the value) over a custom impl, it makes the contract hold by construction and removes the chance to break it.