Pure vs Impure Pipes
Every Angular pipe is either pure or impure, and that single flag decides how often Angular runs its transform method during change detection. Pure pipes — the default — behave like memoised functions: Angular only re-evaluates them when their inputs change by reference. Impure pipes run on every change-detection cycle, which is occasionally necessary but easy to abuse. Understanding the difference is the key to writing pipes that are correct and fast.
What “purity” means
A pure pipe is one whose output depends only on its inputs, with no observable side effects. Given the same input value and the same arguments, it always returns the same result. Because of that contract, Angular can safely cache the last result and skip the pipe entirely unless an input reference changes.
By default a pipe is pure. You opt out by setting pure: false in the @Pipe decorator.
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ name: 'reverse', standalone: true }) // pure by default
export class ReversePipe implements PipeTransform {
transform(value: string): string {
return value.split('').reverse().join('');
}
}
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ name: 'filterActive', standalone: true, pure: false }) // impure
export class FilterActivePipe implements PipeTransform {
transform<T extends { active: boolean }>(items: T[]): T[] {
return items.filter((item) => item.active);
}
}
When each pipe runs
Angular runs change detection frequently — after events, timers, HTTP responses, and microtasks. The purity flag controls whether your pipe participates in each of those passes.
| Behaviour | Pure pipe (default) | Impure pipe (pure: false) |
|---|---|---|
| Re-runs when input reference changes | Yes | Yes |
| Re-runs when input is mutated in place | No | Yes |
| Re-runs every change-detection cycle | No | Yes |
| Result is cached | Yes | No |
| Typical cost | Negligible | Can be heavy |
A pure pipe uses the same reference check Angular uses for OnPush components: it compares the new input to the previous one with === for objects and value equality for primitives. If the reference is unchanged, the cached output is reused and transform is never called.
The reference-change gotcha
This is the most common source of confusion. Mutating an array or object in place does not change its reference, so a pure pipe will not notice.
import { Component } from '@angular/core';
import { FilterActivePipe } from './filter-active.pipe';
@Component({
selector: 'app-tasks',
standalone: true,
imports: [FilterActivePipe],
template: `
@for (task of tasks | filterActive; track task.id) {
<li>{{ task.label }}</li>
}
<button (click)="addTask()">Add active task</button>
`,
})
export class TasksComponent {
tasks = [{ id: 1, label: 'Ship release', active: true }];
addTask(): void {
// Mutation: a PURE pipe would NOT re-run, because `tasks` is the same array.
this.tasks.push({ id: 2, label: 'Write notes', active: true });
}
}
With the impure filterActive pipe above, the new task appears immediately because the pipe re-runs every cycle. If you had marked it pure, the list would not update until you replaced the array reference (this.tasks = [...this.tasks, newTask]).
The “fix” of making a pipe impure is usually the wrong one. Prefer keeping the pipe pure and updating state immutably — replace the reference instead of mutating it. Immutable updates also play well with
OnPushchange detection and signals.
Performance implications
An impure pipe’s transform fires on every change-detection cycle, which can mean hundreds or thousands of calls per second in an interactive app. If the work inside is expensive — filtering a large list, sorting, or formatting — that cost is paid repeatedly even when nothing relevant changed.
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ name: 'logRun', standalone: true, pure: false })
export class LogRunPipe implements PipeTransform {
private count = 0;
transform(value: unknown): unknown {
console.log(`transform call #${++this.count}`);
return value;
}
}
Output:
transform call #1
transform call #2
transform call #3
transform call #4
Each unrelated event — a mouse move, a keystroke, a timer tick — adds another line. A pure pipe in the same template would log only when its input reference actually changed.
Better alternatives to impure pipes
Most cases that tempt you toward an impure pipe have a cleaner, faster solution:
- Immutable updates keep a pure pipe working: build a new array/object instead of mutating.
- Computed signals derive filtered or sorted views reactively and recompute only when a dependency changes.
- The
asyncpipe is itself impure by design, but purpose-built — use it for observables rather than rolling your own.
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-tasks-signal',
standalone: true,
template: `
@for (task of activeTasks(); track task.id) {
<li>{{ task.label }}</li>
}
`,
})
export class TasksSignalComponent {
tasks = signal([{ id: 1, label: 'Ship release', active: true }]);
activeTasks = computed(() => this.tasks().filter((t) => t.active));
}
The computed signal recomputes only when tasks changes, giving you the convenience of an impure pipe with the efficiency of a pure one.
Best Practices
- Leave pipes pure unless you have a concrete, measured reason to make them impure.
- When a pure pipe seems “not to update,” update your state immutably instead of switching to
pure: false. - Keep
transformfree of side effects so pure-pipe caching stays correct. - For derived collections, prefer
computedsignals or precomputed component state over impure filter/sort pipes. - If you must write an impure pipe, keep its work cheap and consider memoising inside the pipe instance.
- Remember the
asyncpipe is impure for a reason — lean on it rather than inventing your own subscription pipe.