Ch. 4 · Angular

Angular Pure and Impure Pipes

Why a pure pipe ignores array.push(), what pure: false costs per change detection pass, and why reading a signal inside transform goes stale.

~6 min readintermediateupdated Oct 19, 2026

Every pipe you write is pure unless you say otherwise. That default is the source of one of Angular’s most reported “bugs”: you push an item into an array, the list in the template updates, but the count next to it, which goes through a pipe, doesn’t move. Nothing is broken. The pipe is doing exactly what pure means.

Interviewers like this topic because the answer touches change detection, immutability and, in recent versions, signals. You’ll want to explain the caching rule precisely, say what pure: false actually costs, and know when a pipe isn’t the right tool at all.

What “pure” means to the compiler

A pure pipe is treated like a pure function of its arguments. When the template is checked, Angular compares each argument to the value it passed last time, using === (strictly, Object.is). If nothing changed, it reuses the previous result and doesn’t call transform at all. Primitives compare by value, so a string or number changing reruns the pipe. Objects and arrays compare by reference, so mutating them in place looks like “no change”.

Each place you use a pipe in a template gets its own instance and its own cache. Two {{ tasks | openTasks }} bindings in one template create two OpenTasksPipe instances, each remembering its own last input. So caching doesn’t leak between bindings, and it also doesn’t help across them.

An impure pipe (pure: false) skips the comparison. Angular calls transform every time it checks the view containing the binding. The built-in impure pipes are async, json, keyvalue and slice. async has to be impure because the value it shows changes when the observable emits, not when the observable reference changes. date, currency, number, uppercase and friends are all pure.

Watching the difference in a real component

Here’s a board with one pure and one impure version of the same filter. This was run in a zone.js TestBed on Angular 20, calling fixture.detectChanges() and counting transform calls.

interface Task { title: string; done: boolean; }

@Pipe({ name: 'openTasks' })
export class OpenTasksPipe implements PipeTransform {
  transform(tasks: Task[]): Task[] {
    return tasks.filter((t) => !t.done);
  }
}

@Pipe({ name: 'openTasksImpure', pure: false })
export class OpenTasksImpurePipe implements PipeTransform {
  transform(tasks: Task[]): Task[] {
    return tasks.filter((t) => !t.done);
  }
}

@Component({
  selector: 'app-board',
  imports: [OpenTasksPipe, OpenTasksImpurePipe],
  template: `
    <p>Pure: {{ (tasks | openTasks).length }} open</p>
    <p>Impure: {{ (tasks | openTasksImpure).length }} open</p>
  `,
})
export class Board {
  tasks: Task[] = [
    { title: 'Write release notes', done: false },
    { title: 'Tag v2.4.0', done: true },
  ];
}
TypeScript

The results, step by step:

Step Pure shows Impure shows Impure transform calls so far
First render 1 1 2
tasks.push({ title: 'Update docs', done: false }), then detect 1 2 4
Two more detect calls, no data change 1 2 8
tasks = [...tasks], then detect 2 2 10

Three things fall out of that table.

The pure pipe ignored the push. Same array reference, so Angular returned the cached result: one open task, while the list clearly has two. Replacing the array with a copy fixed it immediately.

The impure pipe was always right, but it ran on every check, including the two where nothing changed. In development mode Angular runs a second verification pass after each check (the one that throws ExpressionChangedAfterItHasBeenCheckedError), so each detectChanges() called it twice. In a production build it’s once per pass. With zone.js, a pass happens after every click, keystroke, timer and HTTP response anywhere in the app, so a filter over 5,000 rows marked impure runs 5,000-element filters on every mouse event that triggers change detection.

One more surprise if you go back to the pure version: the pipe’s output is a new array each time it runs (filter always returns one). That’s fine for a pure pipe because it only runs when the input changes. In an impure pipe, returning a fresh array on every call would make an @for or a child component’s input see a new reference every pass. Here we only read .length, but a real impure pipe that returns objects should cache and return the same reference when nothing changed.

The signal trap

This one catches people who’ve moved to signals. Suppose a translation pipe reads the current language from a service:

@Injectable({ providedIn: 'root' })
export class Lang {
  readonly current = signal('en');
}

@Pipe({ name: 't' })
export class TranslatePipe implements PipeTransform {
  private readonly lang = inject(Lang);
  transform(key: string): string {
    return DICTIONARY[this.lang.current()][key];
  }
}
TypeScript

The template shows {{ 'save' | t }}. Call lang.current.set('fr') and, in a zoneless TestBed run, the button still reads “Save” while an otherwise identical impure pipe next to it switches to “Enregistrer”. The signal change does schedule a re-render of the view, but when Angular reaches the pipe it compares the argument, 'save', finds it unchanged and reuses the cached string. Signals read inside transform don’t bypass the pure-pipe cache.

The clean fix keeps the pipe pure and makes the hidden dependency an explicit argument: {{ 'save' | t: lang.current() }}. Now a language change is an argument change, and the pipe reruns exactly when it should. Libraries that can’t ask you to do that (ngx-translate’s translate pipe is one) mark their pipe impure and cache internally instead.

Choosing between a pipe, computed() and a method call

Pipes exist for formatting that’s reused across many templates: dates, currencies, file sizes, relative times. They’re also the only option for transforming values that exist only in the template, such as an @for loop variable.

For data derived from component state, a computed() signal is usually better than a custom pipe:

readonly tasks = signal<Task[]>([]);
readonly openTasks = computed(() => this.tasks().filter((t) => !t.done));
TypeScript

It’s memoised like a pure pipe, it’s testable without a template, and it’s shared by every place in the template that reads it, instead of one cache per binding. It has the same identity rule, though. this.tasks().push(task) won’t notify anyone; you write this.tasks.update((list) => [...list, task]).

Calling a method in the template, {{ countOpen(tasks) }}, behaves like an impure pipe without the caching option: it runs on every check. That’s fine for something trivial and a problem for anything that loops over large data.

Practice question: the stale badge

“A sidebar badge shows {{ notifications | unreadCount }}. A WebSocket handler does this.notifications.push(msg). The notification list in the main panel updates, but the badge stays at 3. A colleague proposes pure: false. What’s your response?”

Answer. The badge is stale because unreadCount is pure and the array is mutated in place, so the reference never changes and Angular returns the cached count. The list updates because @for diffs the array contents on every check. Marking the pipe impure would make the badge correct, but it’d also run the count on every change detection pass for the life of the page, which for a WebSocket-heavy screen is a lot of passes. I’d fix the data flow instead: replace the array on each message (this.notifications = [...this.notifications, msg]), or better, hold notifications in a signal and expose unreadCount = computed(() => this.notifications().filter((n) => !n.read).length). If the component uses OnPush, the immutable update matters twice over, because an OnPush child that receives the array as an input also won’t re-render for an in-place push. I’d only reach for pure: false when the pipe genuinely depends on something outside its arguments that I can’t pass in.

More in Angular

read ✓Angular · hard

Angular Zoneless Change Detection

Without zone.js, Angular renders only when notified. See which updates notify, which silently don't, and how TestBed catches the gaps for you.

~6 min readread →
read ✓Angular · mid

Angular Defer Blocks and Lazy Rendering

How @defer splits a template region into its own chunk, which trigger to pick, and why SSR renders only the placeholder unless you opt in.

~7 min readread →
esc