I still remember the first time I had to diagnose a compiler bug that only showed up after a routine refactor. The source code looked fine, the assembly was bizarre, and nothing in the front end seemed wrong. The breakthrough came when I inspected the intermediate representation: the compiler’s “working language” between source and target. That layer explained the mismatch and showed me exactly where the transformation went sideways. Since then, I treat IR as the central artifact for understanding compilers, static analysis, and even modern build tooling. If you write compilers, transpilers, linters, or code generators, you should view IR as your main lens.
In this post, I’ll show you how IR acts as the core data structure inside a compiler, why its design choices shape compile-time and runtime outcomes, and how different IR forms enable different kinds of analysis. I’ll also walk through naming discipline, show concrete code-style IR examples, and share common mistakes I see in real projects. You’ll leave with a mental model you can apply whether you are building a DSL compiler, working with LLVM-style toolchains, or simply trying to understand what happens between source code and machine code.
IR as the compiler’s working language
When I say intermediate representation, I mean any program form between the source language and the target language. That can be a single IR that stays stable throughout the pipeline or a sequence of IRs that gradually move from high-level constructs to low-level, target-ready instructions. Either way, the IR is the central data structure. Everything in the compiler revolves around it: parsing feeds it, analyses read it, transformations rewrite it, and code generation consumes it.
This explains why IR design is not a cosmetic choice. The decisions you make about IR form, naming, and abstraction level shape the compiler’s speed and capability. If the IR is too high-level, some optimizations become hard or ambiguous. If it’s too low-level, you lose semantic info that would make higher-level transformations feasible. The real challenge is picking a representation that balances generation simplicity, manipulation effort, and information content.
I like to think of IR as the “assembly for your compiler,” except it’s not necessarily machine assembly. It is the language where your analysis and transformations live. If you care about predictable builds, reliable refactoring, and meaningful diagnostics, you should be fluent in that language.
Why IR exists: the practical reasons
I use IR for three very concrete reasons:
1) Translating code requires analysis and synthesis. I cannot reason about types, control flow, or data flow without a structured, inspectable representation. IR is that structure.
2) Machine-independent transformations live at the IR level. If I want a transformation to apply across target architectures, the IR is the place to do it.
3) IR makes translation simpler. It creates an internal common language so I don’t need to translate every source feature directly to every target feature.
When you have multiple source languages or multiple target architectures, this becomes essential. A single IR means you implement fewer translation paths. Instead of building N×M translators, you build N front ends into IR and M back ends from IR. That composition scales with your ambition.
The five core properties I weigh in an IR
Different compilers prioritize these properties differently. I treat them as a set of trade-offs rather than a checklist you must satisfy completely.
- Ease of generation: If your front end can’t generate the IR without heroic effort, you’ll accumulate bugs and slow down compilation. An IR should be easy to produce from your AST or parse tree.
- Ease of manipulation: Your analyses and transformations must be able to traverse, query, and rewrite the IR without complex bookkeeping.
- Freedom of expression: The IR should be expressive enough to capture source semantics without losing meaning.
- Size of the procedure: The IR should not explode in size. Larger IRs slow analyses and consume memory.
- Level of abstraction: The IR should expose the right amount of detail for your intended transformations.
I usually start with the transformations I want to support, then work backward to the minimal IR detail needed. This keeps the representation practical and prevents me from over-complicating the pipeline.
Levels of abstraction and what they enable
The abstraction level has a direct impact on what you can analyze or transform. I group levels like this:
High level
High-level IR is close to the source language. It preserves structured constructs such as arrays, structs, and procedure calls. One IR operation might correspond to a complex source-level action, not a single machine instruction.
In my experience, high-level IR is great for alias analysis and memory disambiguation because high-level structures are still visible. It also preserves source intent, which is valuable for diagnostics, source mapping, and refactoring tools.
Medium level
Medium-level IR removes structured objects but stays independent of any specific target. It can still reflect source or target orientation depending on the design. I often use medium-level IR as a sweet spot where control flow and data flow are explicit, but the representation remains general-purpose.
Low level
Low-level IR is close to the target language. A single IR operation usually matches a low-level action, sometimes even a micro-operation. Several IR steps may map to one target instruction or vice versa, depending on the target’s capabilities.
Low-level IR is excellent for target-specific lowering and backend scheduling, but it loses high-level semantic details. That can make high-level transformations harder or impossible.
If you’re building a compiler pipeline, I recommend a staged approach: start with a high-level IR for semantic clarity, lower into medium-level IR for platform-agnostic analysis, then produce a low-level IR for target-specific code generation. This gives you flexibility without forcing any single representation to do all the work.
Types of IR and where I use them
I classify IRs into three main categories. The choice depends on the transformation tasks you care about.
Graphical IR
Graphical IR is a graph-based representation. It tends to be larger but is incredibly expressive. I use it when I need strong structural relationships, such as dependencies or control flow.
Two main subtypes matter:
- Syntax-related trees: Parse Trees, Abstract Syntax Trees (ASTs), and Directed Acyclic Graphs (DAGs). ASTs are excellent for source-oriented transformations. DAGs reduce redundancy by sharing common subexpressions.
- Graphs: Control Flow Graphs (CFGs), Dependency Graphs, and Call Graphs. These enable analyses like reachability, dominance, and interprocedural flow.
Even though they all have nodes and edges, they differ by structure, abstraction, and their relationship to the underlying code. If you want to analyze control flow, you reach for a CFG. If you want to reason about expression reuse, you reach for a DAG.
Linear IR
Linear IR is pseudo-code for an abstract machine. It uses simple, compact data structures and is easy to rearrange. The abstraction level can vary across a pipeline.
Examples include stack machine code and three-address code. I like linear IR for transformations that need a straightforward list of instructions, such as local algebraic simplification or register allocation preparation.
Hybrid IR
Hybrid IR combines graphical and linear forms. This is common in production compilers where you want both explicit control flow graphs and instruction lists. You can model control flow as graphs while each node holds a linear block of instructions. This hybrid approach gives the best of both worlds: strong structure for analysis and a straightforward instruction order for lowering.
Naming discipline: why temporary names matter
Naming in IR isn’t just a style choice; it affects compile-time and memory usage. The compiler must assign names to distinct values during translation. Consider the expression:
x + 5 * y
A possible evaluation sequence is:
- t1 ← y
- t2 ← 5 * t1
- t3 ← x
- t4 ← t3 + t2
These names, t1 through t4, are temporary values. You can reduce the number of names by reusing temporaries when lifetimes do not overlap. For example, you can reuse the same temporary for t2 and t4 if your analysis proves the older value is no longer needed.
Why do I care? Because the naming scheme influences the size of internal data structures. Fewer temporaries can mean smaller liveness sets, leaner symbol tables, and lower memory use during compilation. On large programs, those savings add up.
I recommend a naming policy that is stable, deterministic, and designed around lifetime analysis. If you plan to serialize IR for debugging, deterministic naming also makes diffs readable and helps you reproduce issues faster.
A concrete IR walk-through with runnable examples
Let me show how I translate a tiny snippet into a three-address style IR, then apply a small transformation. I’ll use Python for the example so you can run it and get a feel for IR construction.
Source snippet
We will transform this:
- Compute revenue = price * quantity
- If revenue exceeds a threshold, apply a discount
Here is a small Python program that lowers this logic into a basic linear IR and performs a simple constant folding pass.
from dataclasses import dataclass
from typing import List, Union, Dict
@dataclass
class Instr:
op: str
args: List[str]
dst: str = ""
class IRBuilder:
def init(self):
self.temp_id = 0
self.code: List[Instr] = []
def temp(self) -> str:
self.temp_id += 1
return f"t{self.temp_id}"
def emit(self, op: str, args: List[str], dst: str = "") -> str:
if not dst:
dst = self.temp()
self.code.append(Instr(op, args, dst))
return dst
def build(self):
return self.code
Build a tiny IR program
builder = IRBuilder()
price = "price"
quantity = "quantity"
threshold = "1000"
revenue = builder.emit("mul", [price, quantity])
cond = builder.emit("gt", [revenue, threshold])
Branch representation: if cond goto label
builder.emit("brif", [cond, "labeldiscount"])
Fallthrough
builder.emit("label", ["label_end"], dst="")
ir = builder.build()
Simple constant folding pass
consts: Dict[str, Union[int, None]] = {}
folded: List[Instr] = []
for ins in ir:
if ins.op in ("mul", "gt"):
left, right = ins.args
if left.isdigit() and right.isdigit():
value = int(left) * int(right) if ins.op == "mul" else int(left) > int(right)
consts[ins.dst] = int(value)
folded.append(Instr("const", [str(int(value))], ins.dst))
else:
consts[ins.dst] = None
folded.append(ins)
else:
folded.append(ins)
print("\n".join(f"{i.dst} = {i.op} {‘, ‘.join(i.args)}" if i.dst else f"{i.op} {‘, ‘.join(i.args)}" for i in folded))
This example is intentionally small, but it shows the workflow: build IR, then apply a pass. In real compilers, you’ll add control flow blocks, SSA form, and richer metadata. But the basic mechanism is the same: represent and transform program logic in a form that’s easy to inspect and rewrite.
Why this matters
Even a simple IR like the one above makes it clear where and how transformations happen. You can trace which values flow into the comparison, see which branches are reachable, and attach metadata about types or ranges. This is hard to do directly on source syntax or target assembly.
Modern compilers and IR: traditional vs modern workflows
The basic idea of IR hasn’t changed, but how we work with it has. Here’s how I think about traditional and modern practices when building or using an IR-based compiler pipeline.
Traditional approach
—
Single monolithic IR
Inspect IR dumps manually
Handwritten passes
Ad-hoc temp names
Manual build scripts
I still do manual IR dumps when needed, but in 2026 I also lean on automated IR comparison tools and AI-assisted refactoring. The key is to keep IR stable enough that those tools produce consistent results.
Common mistakes I see in IR design
I’ve seen teams ship compilers that “work” but are painfully slow or brittle because the IR design was rushed. Here are mistakes I watch for:
- Mixing abstraction levels in a single IR stage. This makes passes hard to reason about because some instructions are high-level and others are low-level. If you need both, split stages.
- Losing semantic information too early. Once you lower to a low-level IR, you can’t reliably recover source semantics. Keep a higher-level IR around for analyses that depend on source intent.
- Overly verbose IR. If every source operation explodes into many IR instructions, your analysis passes slow down and memory use climbs.
- Non-deterministic naming. If the same source produces different IR names between runs, debugging becomes painful and caching is unreliable.
- Rewriting without preserving invariants. If a pass doesn’t maintain dominance, SSA correctness, or block ordering, downstream passes will fail in subtle ways.
The fix is usually to clarify invariants and enforce them. Every IR stage should document its rules: what operations are valid, what control flow forms are allowed, and what metadata must be preserved.
When to use a specific IR form
You should choose the IR form that matches your dominant tasks. Here’s how I decide:
- Use graphical IR when you need structural analysis: control flow, reachability, dependency tracking, and interprocedural reasoning.
- Use linear IR when you need simple rewriting or direct lowering to target instructions.
- Use hybrid IR when you need both: graph-based control flow plus linearly ordered instructions within blocks.
And here’s when I avoid certain forms:
- I avoid graph-only IR for low-level backend passes; it can be too heavy and slow.
- I avoid linear-only IR for high-level semantic analysis; it hides relationships that are explicit in a graph.
If you’re not sure, start with a hybrid structure: a CFG whose blocks contain a linear list of instructions. This provides strong analysis while keeping lowering manageable.
Performance considerations with real-world ranges
IR design affects compile-time performance. In large systems I typically see:
- Basic analysis passes run in 10–30ms per medium-sized function.
- More complex interprocedural passes might cost 50–200ms depending on graph size.
- If your IR is too verbose, these numbers can easily double.
The biggest cost drivers are usually memory allocation and graph traversal. I recommend:
- Keeping instruction payloads small and using interned strings or IDs.
- Using stable data structures for blocks and edges.
- Caching derived facts when possible, but invalidating them correctly after transformations.
A good rule of thumb is: if a pass needs more than linear time in the number of IR nodes, you should justify it with a measurable gain. Otherwise, you’ll spend too much time compiling and too little time running the program.
A practical checklist for IR design
When I design or review an IR, I use a short checklist:
- Does each IR stage have a clear abstraction level?
- Can the front end generate it without complex rewrites?
- Are transformations and analyses easy to implement and debug?
- Is the naming scheme deterministic and memory-efficient?
- Are invariants clearly documented and enforced?
If I can’t answer “yes” to most of these, I revise the design before writing more passes.
Edge cases you should handle explicitly
IR bugs often appear in edge cases that seem harmless. I always test at least these scenarios:
- Nested control flow with early exits (break, continue, return)
- Short-circuit boolean expressions
- Function calls with side effects inside expressions
- Loops with multiple back edges
- Switch-like constructs with fallthrough semantics
These are the places where control flow graphs, temporary naming, and evaluation order frequently go wrong. A small test harness that emits IR for these patterns is worth the time.
Practical next steps and how I’d apply this
If you’re building a compiler or analysis tool today, I’d start by choosing a staged IR pipeline: high-level for semantic clarity, medium-level for general analyses, and low-level for backend work. Then I’d build a tiny end-to-end slice that goes from source to IR to a trivial backend. That slice becomes a reference you can expand with confidence.
I also recommend you treat IR dumps as a first-class artifact. Save them during builds, compare them across changes, and keep them stable enough to diff. That practice turns debugging from guesswork into a straightforward investigation.
Finally, keep your IR language small and deliberate. Each new instruction should justify its existence by enabling a transformation or simplifying a pass. In my experience, fewer well-designed instructions lead to faster compilers and fewer defects than a huge, loosely defined set.
If you adopt that mindset, IR stops feeling like an internal detail and starts feeling like the core language your compiler speaks. Once you treat it that way, everything in the toolchain becomes easier to reason about, from diagnostics to code generation to performance tuning. That is the real power of intermediate representation: it gives you a clear, inspectable, and malleable language that sits between idea and machine.


