- Published on
DefId vs HirId in the Rust Compiler
- Authors

- Name
- Mehdi Akiki
As I will be contributing to the source code of rustc, I needed to dig deeper to understand both DefId and HirId. So at first, I was confused. It is because they look similar but realized that they live at completely different layers of the compiler.
The two representations
rustc lowers Rust source through several intermediate representations:
Source → AST → HIR → THIR → MIR → codegen
HIR (High-level Intermediate Representation) is the first major lowering. It strips syntactic sugar — for loops become loop/match, impl Trait becomes a named parameter — but it stays close to what you wrote. It exists only for the current crate, only during the current compilation session.
MIR (Mid-level IR) is a control-flow graph of basic blocks. It's what the borrow checker and the optimizer work on. This is also where cross-crate information becomes central: to inline a function from serde, the compiler needs to refer to it from a compiled artifact, not from source.
HirId lives in HIR. DefId spans the whole pipeline and crosses crate boundaries.
HirId — a node in the current crate's tree
pub struct HirId {
pub owner: OwnerId, // which item owns this node (e.g., which function)
pub local_id: ItemLocalId, // which node within that item
}
HirId identifies any node in the HIR tree: an expression, a pattern, a block, a let-binding, a match arm. The key word is any. Not everything in HIR is a named definition. The literal 42 in let x = 42; gets a HirId. It has no name, no definition, just a position in the tree.
HirId is always local. You cannot construct a HirId that refers to something in another crate, because other crates don't have HIR — only their compiled metadata survives.
Typical use: the TypeckResults struct (the output of type-checking a function body) is keyed by HirId. When you ask "what type does this expression have?", you look up the expression's HirId.
// Inside a lint or a compiler pass
let ty = cx.typeck_results().expr_ty(expr); // expr.hir_id is the key internally
DefId — a definition, anywhere
pub struct DefId {
pub krate: CrateNum, // which crate (0 = LOCAL_CRATE)
pub index: DefIndex, // which definition within that crate
}
DefId only exists for definitions — things that can be named and referenced: functions, structs, enums, traits, impl blocks, constants, type aliases, modules. Not expressions. Not patterns.
Because it carries a CrateNum, a DefId can point into any crate the compiler has loaded. std::vec::Vec has a DefId. serde::Serialize has a DefId. Your local fn main has a DefId too (with krate == LOCAL_CRATE).
When krate == LOCAL_CRATE, the compiler often uses LocalDefId instead — a newtype that makes the local constraint explicit and avoids a redundant field.
Typical use: the query system is almost entirely keyed by DefId. "What are the generic parameters of this function?" → tcx.generics_of(def_id). "What are the trait bounds?" → tcx.predicates_of(def_id). "Give me the MIR body" → tcx.optimized_mir(def_id).
Conversions between them
Not every HirId has a DefId — only the nodes that are definitions:
// HirId → LocalDefId (only works if the node is a definition)
let local_def_id: LocalDefId = cx.tcx.hir().local_def_id(hir_id);
// LocalDefId → DefId (always works, just sets krate = LOCAL_CRATE)
let def_id: DefId = local_def_id.to_def_id();
Going the other way:
// LocalDefId → HirId (always works for local defs)
let hir_id: HirId = cx.tcx.local_def_id_to_hir_id(local_def_id);
// DefId → HirId only works if the def is local
if let Some(local) = def_id.as_local() {
let hir_id = cx.tcx.local_def_id_to_hir_id(local);
}
There is no conversion from a foreign DefId to a HirId — foreign crates have no HIR.
The mental model
HirId | DefId | |
|---|---|---|
| What it identifies | Any HIR node (incl. expressions) | Named definitions only |
| Cross-crate? | No | Yes |
| Survives past HIR? | No | Yes (through MIR, codegen) |
| Primary use | Type-checking results, linting | Query system, trait solving, codegen |
If you're writing a lint or walking the HIR tree, you'll work with HirId. If you're asking the compiler about types, traits, or generics — or if you need to refer to something from another crate — you'll work with DefId.
The confusion usually hits when you have one and need the other. The rule: if the node is a definition, the compiler can give you both; if it's just a node (an expression, a pattern), only HirId exists.
Seeing them in practice
You don't need to write a compiler pass to observe these IDs. rustc has unstable flags that dump the intermediate representations directly to stdout. The ,identified modifier annotates every HIR node with its HirId inline as a comment.
Start with the simplest possible crate — cargo new hir-explore, leave main.rs as the default hello-world — then run:
cargo +nightly rustc -- -Z unpretty=hir,identified
Real output (trimmed for readability):
extern crate std;
/* hir_id: HirId(DefId(0:1 ~ hir_explore[711f]::std).0) */
#[attr = PreludeImport]
use std::prelude::rust_2024::*;
/* hir_id: HirId(DefId(0:2 ~ hir_explore[711f]::{use#0}).0) */
fn main()
/* hir_id: HirId(DefId(0:3 ~ hir_explore[711f]::main).0) */
({
({
(::std::io::_print
/* expr hir_id: HirId(DefId(0:3 ~ hir_explore[711f]::main).5) */
)(
(format_arguments::from_str
/* expr hir_id: HirId(DefId(0:3 ~ hir_explore[711f]::main).13) */
)(
("Hello, world!\n"
/* expr hir_id: HirId(DefId(0:3 ~ hir_explore[711f]::main).14) */)
)
/* expr hir_id: HirId(DefId(0:3 ~ hir_explore[711f]::main).9) */
)
/* expr hir_id: HirId(DefId(0:3 ~ hir_explore[711f]::main).4) */
} /* block hir_id: HirId(DefId(0:3 ~ hir_explore[711f]::main).3) */);
} /* block hir_id: HirId(DefId(0:3 ~ hir_explore[711f]::main).1) */)
A few things stand out immediately.
println! is gone. The macro has been fully expanded into ::std::io::_print(format_arguments::from_str("Hello, world!\n")). HIR has no macros — by the time HIR is built, every macro call has already been resolved. This is the first representation where you see the actual function calls the compiler will work with.
The implicit items are visible. The extern crate std and the prelude import (use std::prelude::rust_2024::*) were never in your source file. The compiler inserts them silently. In HIR they appear as real nodes with their own HirIds.
Reading a HirId. Take HirId(DefId(0:3 ~ hir_explore[711f]::main).5):
HirId(
DefId(0:3 ~ hir_explore[711f]::main) ← the owner: which definition contains this node
0 = LOCAL_CRATE, 3 = def index, 711f = crate hash
.5 ← local_id: which node within that definition
)
The owner for every expression inside main is main's own DefId. The .N suffix counts up as you go deeper into the expression tree — .1 is the outer block, .3 is the inner block, .4 is the call expression, .14 is the string literal. A higher number doesn't mean deeper nesting; it just means it was visited later during HIR construction.
Print the raw HIR tree (no source reconstruction):
cargo +nightly rustc -- -Z unpretty=hir-tree
This prints the actual Rust structs (Expr, Stmt, Block, etc.) with all fields. Useful when you want to see the exact variant names you'd match on inside a compiler pass.
Print MIR (which uses DefIds):
cargo +nightly rustc -- -Z unpretty=mir
# or dump each body to a separate file under mir_dump/
cargo +nightly rustc -- -Z dump-mir=all
MIR output labels each body with its DefId path (e.g. hir_explore::main) and uses DefIds for all type references and function calls within the body.
Print a DefId as a human-readable path from inside a compiler pass:
let path = tcx.def_path_str(def_id); // "std::vec::Vec", "hir_explore::main", etc.
This is the most useful debug call when you have an opaque DefId and want to know what it actually refers to.
Why do both need to exist?
A reasonable question is: why not just one universal ID? The answer is that they solve fundamentally different problems, and collapsing them into one would force every layer of the compiler to carry information it doesn't need.
HIR is ephemeral and crate-local by design. The compiler processes one crate at a time. Once HIR is lowered to MIR, the HIR arena is freed. Keeping an ID system that tracks individual expressions inside function bodies across crate boundaries would mean retaining an enormous amount of data that external consumers (other crates, the linker, incremental compilation caches) have no use for.
Definitions need to survive compilation. When you compile serde and then compile your crate that depends on it, rustc doesn't re-parse serde's source — it reads the compiled .rlib metadata. That metadata contains DefIds. Any query that touches a foreign type, trait implementation, or function goes through a DefId. A system based purely on HIR nodes couldn't express this at all.
The split also enables incremental compilation. The query system memoizes results keyed by DefId. If you change one function, only the queries that depend on that function's DefId are invalidated. There's no equivalent granularity for HirId — re-typechecking a function body re-generates all the HirIds inside it anyway.
In short: HirId is the right tool for "where in the source tree is this?", and DefId is the right tool for "what is this thing and how does the rest of the world refer to it?". They answer different questions.
FAQ
Can two different nodes have the same HirId? No. HirId is unique within a crate for a given compilation session. The combination of owner (which definition contains the node) and local_id (which node within that definition) is always unique.
Can a DefId become invalid between compilations? Yes, in the sense that DefIndex values are not stable across compilations — they're assigned during each compile. Incremental compilation handles this by mapping DefIds through a stable hash of the definition's path (DefPathHash). If you're storing DefIds anywhere persistent, you want the hash, not the raw index.
What is LocalDefId and when should I use it over DefId? LocalDefId is just DefId with the krate field removed — it's a newtype that statically guarantees you're referring to something in the current crate. Prefer it in any pass that only operates on local definitions. It avoids a redundant check and makes the intent clear at the type level.
Why does HirId have an owner field? Couldn't it just be a flat index? The owner field exists for parallel compilation. Each "owner" (roughly: each item that can have its own MIR body, like a function) can be type-checked independently. The owner + local_id split means the compiler can assign HirIds within each owner without coordinating a global counter, which would be a synchronization bottleneck.
I have a Span. How do I get a HirId from it? You generally can't go directly from a Span to a HirId — spans are source positions, not tree node identities. The typical pattern is to visit the HIR tree and match on the node you care about, then use the HirId the visitor hands you. tcx.hir().find_by_def_id(local_def_id) can help if you already know which definition you're looking at.
I build and scale reliable production systems. Open to full-time and freelance work with U.S.-based teams that value ownership and execution.
Got something in mind?
Book a Discovery Call