This is Part 4 of a 6-part series on Angular Schematics.
- ✅ Part 1 – Understanding Angular Schematics: Architecture & Core Concepts
- ✅ Part 2 – Creating Custom Generators with ng generate
- ✅ Part 3 – Building Installation Schematics with ng add
- Part 4 – Writing Migration Schematics with ng update ← You are here
- ? Part 5 – Testing Schematics with Angular DevKit
- ? Part 6 – Advanced Patterns, Publishing & Nx Integration
? A note on this article: I used Claude to help reformat and structure the content to make it clearer and more presentable for publication.
References
- Angular CLI – ng update – Official CLI reference for ng update
- Angular DevKit Schematics – Full README – Complete reference for
@angular-devkit/schematics - Angular Update Guide – How Angular ships its own migrations
- TypeScript Compiler API – TypeScript AST documentation
The ng add schematic we built in Part 3 handled first-time library setup. Earlier, in Part 2, we created ng generate schematics for on-demand code generation and routine operations.
In both scenarios, a developer explicitly triggers the schematic. Part 4 introduces a fundamentally different approach: ng update. Here, the CLI takes the initiative—when a package version is upgraded, code transformations execute automatically without any manual step from the developer.
This is precisely how Angular pushes its own breaking changes. The code rewriting you experience when running ng update @angular/core is powered by a migration schematic. All library authors have access to this same infrastructure.
After completing this article, you'll have a functional migration schematic capable of:
- Finding a renamed component selector across every template file
- Updating a changed input property binding with the TypeScript Compiler API
- Refreshing the
app.config.tsprovider registration to align with a new API - Executing automatically and in the proper sequence when consumers use
ng update @acme/ui
Table of Contents
- How ng update Works
- migrations.json – The Migration Manifest
- Version Gating – Running the Right Migration at the Right Time
- Project Structure
- Migration 1 – Renaming a Component Selector in Templates
- Migration 2 – Updating app.config.ts Provider Registration
- Composing Migrations with chain
- Safe Transformations – Principles and Guardrails
- Running and Verifying Locally
- Summary & What’s Next

How ng update Works
A developer typing ng update @acme/ui sets off a sequence of events within the CLI:
ng update @acme/ui
│
▼
1. Resolve installed version of @acme/ui
→ reads node_modules/@acme/ui/package.json → e.g. "4.2.0"
│
▼
2. Resolve target version
→ latest on npm registry → e.g. "5.0.0"
│
▼
3. Read "ng-update" field in package.json
→ { "migrations": "./schematics/migrations.json" }
│
▼
4. Read migrations.json
→ find all migrations where version > 4.2.0 and version <= 5.0.0
│
▼
5. Execute matching migrations in version order
→ each migration is a Rule applied to the workspace Tree
│
▼
6. Commit all Tree changes to disk
7. npm install (updated package version)
What sets ng update apart from ng generate comes down to two core distinctions:
Version gating. The CLI tracks both the consumer's current version and their upgrade target. Only migrations within that version range are applied. A consumer moving from 4.0.0 to 5.0.0 receives every migration starting with 4.x. Someone already on 4.9.0 gets only the migrations not yet executed.
Automatic execution. The developer doesn't need any awareness that migrations exist. Running ng update causes the CLI to apply all matching migrations automatically; the consumer then examines the resulting changes. No prior knowledge of how the migration system functions is required.
migrations.json – The Migration Manifest
While collection.json serves as the registry for ng generate and ng add schematics, migrations.json fulfills that same role for ng update. Each of its entries describes a single migration, references the version it targets, and points to the factory function meant to run.
{
"$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
"schematics": {
"migration-v5-rename-selector": {
"version": "5.0.0",
"description": "Renames acme-data-grid selector to acme-table across all templates",
"factory": "./migrations/v5/rename-selector#renameSelector"
},
"migration-v5-update-provider": {
"version": "5.0.0",
"description": "Updates provideAcmeUi() call signature in app.config.ts",
"factory": "./migrations/v5/update-provider#updateProvider"
}
}
}
The mechanism for version gating hinges on the "version" field. It declares the target version that this migration prepares the consumer's codebase for. The CLI engages the migration when the consumer upgrades to that version, regardless of what earlier version they were on.
To make this manifest discoverable, reference it in your library's package.json:
{
"name": "@acme/ui",
"version": "5.0.0",
"schematics": "./schematics/collection.json",
"ng-update": {
"migrations": "./schematics/migrations.json",
"packageGroup": ["@acme/ui", "@acme/ui-icons", "@acme/ui-charts"]
}
}
For libraries with interdependent packages, the packageGroup field, although not mandatory, matters a lot. It instructs the CLI to handle these packages as a unit, so all migrations across the group run in a synchronized order.
Version Gating – Running the Right Migration at the Right Time
The concept of version gating distinguishes ng update migrations from an isolated ng generate run. Take a library that has published migrations for several major versions:
{
"schematics": {
"migration-v3-rename-prefix": {
"version": "3.0.0",
"factory": "./migrations/v3/rename-prefix#renamePrefix"
},
"migration-v4-update-imports": {
"version": "4.0.0",
"factory": "./migrations/v4/update-imports#updateImports"
},
"migration-v5-rename-selector": {
"version": "5.0.0",
"factory": "./migrations/v5/rename-selector#renameSelector"
}
}
}
Jumping from v2.x straight to v5.0.0 triggers all three migrations in order—v3, then v4, and finally v5. If the consumer is already on v4.x, the v5 migration runs without the others. The CLI orchestrates this without any manual intervention. You author each migration against its version boundary, and the CLI makes sure only the relevant ones execute, in the proper sequence.
This pattern is how you properly handle long upgrade journeys. Avoid the temptation to merge migrations across versions—someone who skipped v3 and v4 still requires those migrations. As long as each migration declares its version correctly, the CLI sequences them correctly.
Project Structure
@acme/ui/
├── package.json ← "ng-update" points to migrations.json
└── schematics/
├── collection.json ← ng generate / ng add schematics
├── migrations.json ← ng update migrations manifest
└── migrations/
└── v5/
├── index.ts ← composes all v5 migrations
├── rename-selector/
│ ├── index.ts
│ └── schema.json
└── update-provider/
├── index.ts
└── schema.json
Each migration deserves its own folder inside a version-specific directory. This approach simplifies auditing which migrations accompany which version and keeps each factory function centered on a single transformation.
Migration 1 – Renaming a Component Selector in Templates
The initial migration switches every occurrence of the acme-data-grid selector to acme-table throughout all HTML template files in the workspace. While it's essentially a string replacement across many files, both the opening and closing tags need attention, and the operation must be repeatable.
// schematics/migrations/v5/rename-selector/index.ts
import { Rule, Tree } from '@angular-devkit/schematics';
export function renameSelector(): Rule {
return (tree: Tree) => {
// Walk every file in the workspace
tree.visit((filePath) => {
// Only process HTML template files
if (!filePath.endsWith('.html')) return;
const content = tree.read(filePath);
if (!content) return;
const original = content.toString('utf-8');
// Replace both opening and closing tags
const updated = original
.replace(/<acme-data-grid/g, '<acme-table')
.replace(/<\/acme-data-grid>/g, '</acme-table>');
// Only write if something actually changed - avoids unnecessary diffs
if (updated !== original) {
tree.overwrite(filePath, updated);
}
});
return tree;
};
}
This implementation highlights three important aspects:
tree.visit() traverses every file in the entire workspace Tree—if present, that includes node_modules and dist. For production migrations, you'd narrow this to src/ so you don't touch files outside the consumer's application:
tree.visit((filePath) => {
if (!filePath.startsWith('/src/')) return;
if (!filePath.endsWith('.html')) return;
// ...
});
Write only on change. The if (updated !== original) check ensures only files actually containing acme-data-grid get written. This yields a minimal diff—only files with real modifications show up in the UPDATE output.
Global regex flags. You need the g flag here; without it, just the first match in each file gets replaced.
Migration 2 – Updating app.config.ts Provider Registration
Provider configuration for standalone Angular applications lives in app.config.ts. When a library shifts its provider API—say, provideAcmeUi() starts requiring an options argument—the migration must locate and adapt that invocation.
@schematics/angular/utility provides addRootProvider, which is designed explicitly for safely adding or updating provider registrations within standalone application configs. It stands as the official approach to manipulating app.config.ts inside schematics.
// schematics/migrations/v5/update-provider/index.ts
import { Rule } from '@angular-devkit/schematics';
import { addRootProvider } from '@schematics/angular/utility';
export function updateProvider(options: { project: string }): Rule {
// addRootProvider locates app.config.ts for the given project,
// finds the providers array, and inserts the expression safely.
// It handles imports, formatting, and idempotency automatically.
return addRootProvider(options.project, ({ code, external }) =>
code`${external('provideAcmeUi', '@acme/ui')}({ animations: true })`
);
}
A callback passed to addRootProvider returns a code tagged template literal that can include:
external('provideAcmeUi', '@acme/ui')– points to theprovideAcmeUisymbol sourced from@acme/ui, while also guaranteeing the rightimportstatement appears in the file.
The utility performs the heavy lifting: finding app.config.ts, navigating the ApplicationConfig providers array, placing the new entry, and adding the import—no manual file editing needed on your part.
Here's what changes:
Before:
// src/app/app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
]
};
After:
// src/app/app.config.ts
import { provideAcmeUi } from '@acme/ui';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideAcmeUi({ animations: true }),
]
};
? Note on complex TypeScript transformations: The most common provider registration pattern is handled cleanly by
addRootProvider. But when You need deeper TypeScript changes—such as renaming properties across any.tsfile, rearranging import paths, or dealing with multiple overloaded call signatures—the TypeScript Compiler API offers a fully typed AST for precise work. To keep things digestible here, Migrations 1 and 2 rely on the straightforward regex-based template replacement and this utility, covering the most frequent migration needs you'll encounter. The TypeScript Compiler API gets full treatment in Part 6 of this series.

Composing Migrations with chain
The chain function allows individual migrations to be pieced together, similar to how Rules combine in ng generate. This comes in handy for offering a singular ng generate entry point that applies all v5 migrations together—for developers who run them manually, before fully committing to ng update:
// schematics/migrations/v5/index.ts
import { Rule, chain } from '@angular-devkit/schematics';
import { renameSelector } from './rename-selector';
import { updateProvider } from './update-provider';
export function migrateV5(options: { project: string }): Rule {
return chain([
renameSelector(),
updateProvider(options),
]);
}
Alongside the individual migration entries, include this in collection.json:
{
"schematics": {
"migrate-v5": {
"description": "Applies all @acme/ui v5 migrations manually",
"factory": "./migrations/v5/index#migrateV5"
}
}
}
Developers now have flexibility in how they proceed:
# Automatic - runs on ng update, version-gated
ng update @acme/ui
# Manual - opt-in, previewable with --dry-run
ng generate @acme/ui:migrate-v5 --dry-run
ng generate @acme/ui:migrate-v5
This repeats the shared-factory-with-two-delivery-mechanisms concept from Part 2. The underlying Rules are indistinguishable—what varies is solely the trigger and its timing.
Keeping Transformations Safe – Core Rules and Safeguards
A flawed migration can do more harm than skipping it altogether – it can quietly break a developer's codebase. Follow these guardrails to keep things stable:
Do a cheap string check first. Before any replacement logic runs, verify the target string is actually present in the file. This avoids unnecessary processing on files that can't be impacted, significantly speeding up large-scale workspace migrations:
if (!content.includes('provideAcmeUi')) return tree;
Only overwrite when something changed. Compare the transformed content against the original prior to invoking
tree.overwrite(). This keeps the diff minimal – only files with real modifications show up in the
UPDATE log:
if (updated !== original) {
tree.overwrite(filePath, updated);
}
Match as narrowly as possible. When migrating templates, use a regex that pinpoints the exact tag or attribute you're renaming, rather than a generic identifier. The more precise your match, the less likely you are to alter unrelated code.
Leverage official helpers instead of raw text edits. addRootProvider, addDependency, and updateWorkspace from @schematics/angular/utility are built to handle idempotency, structural validity, and edge cases that are easy to botch when doing manual string replacements on structured files like angular.json or app.config.ts.
Constrain tree.visit() to the src/ directory. Without a path filter, traversing the full workspace Tree will sweep in node_modules, dist, and the .angular cache. Add an early filter:
tree.visit((filePath) => {
if (!filePath.startsWith('/src/')) return;
// ...
});
Always test with --dry-run before involving a version control system. While developing, execute your migration against a sample workspace using --dry-run and scrutinize the resulting diff before your first actual commit.
Include tests. Every migration requires unit tests for these cases: a file that contains the target, one that doesn't, one where the change is already present (verifying idempotency), and one with repeated occurrences. Part 5 dives into SchematicTestRunner and UnitTestTree to demonstrate this.
Local Execution and Verification
To test ng update migrations on your own machine, the --from and --to flags allow you to mimic a version shift without modifying the installed package:
# Simulate upgrading from v4.0.0 to v5.0.0
ng update @acme/ui --from=4.0.0 --to=5.0.0 --migrate-only
# Preview without writing - always do this first
ng update @acme/ui --from=4.0.0 --to=5.0.0 --migrate-only --dry-run
# Run a specific migration by name
ng update @acme/ui --migrate-only --name=migration-v5-rename-selector
The --migrate-only flag instructs the CLI to run migrations while leaving the package version in package.json untouched – crucial for development cycles. The --name flag isolates a single migration, which proves handy when fine-tuning one specific change.
Here's what you should see in the terminal after a run completes:
Using package manager: npm
Collecting installed dependencies...
Found 1 migration to apply.
@acme/ui > migration-v5-rename-selector
Renames acme-data-grid selector to acme-table across all templates
UPDATE src/app/features/dashboard/dashboard.component.html (842 bytes)
UPDATE src/app/features/products/products-list.component.html (1203 bytes)
Migration successful.
Wrap-Up and Looking Ahead
We've put together two migration schematics and integrated them into a migrations.json manifest that the Angular CLI discovers automatically when you run ng update.
Essential points to remember:
migrations.json serves as the manifest mapping migrations to their target versions. The "ng-update" entry in package.json directs the CLI to this file. packageGroup ensures synchronized updates among interconnected packages.
Version gating happens by default – the CLI calculates the version difference and only executes the migrations within that range, following version order. Each migration should target its own version boundary; avoid merging multiple versions into one.
Template migrations rely on tree.visit() paired with a scoped path filter and a global regex substitution. Keep it limited to /src/, apply the g flag, and write only when the content genuinely differs.
TypeScript file migrations often succeed with targeted string replacement if the pattern is exact – a recognizable function name, a predictable path, or a no-argument call. Implement a fast string check before replacing and an idempotency check to avoid reapplying.
One factory can handle both ng update (automatic, gated by version) and ng generate (manual, with preview) – just register it in migrations.json and collection.json.
? For intricate TypeScript modifications – renaming properties in various files, altering import paths, or dealing with overloaded signatures – the TypeScript Compiler API is the proper approach. Part 6 of this series tackles that topic.
In Part 5, we're moving to testing – leveraging SchematicTestRunner and UnitTestTree to craft fast and dependable tests for every schematic type discussed throughout this series. Each migration from this article requires a test, and Part 5 walks through exactly how to set them up.
Series Overview
| Part | Topic | Status |
|---|---|---|
| Part 1 | Understanding Angular Schematics – Architecture & Core Concepts | ✅ Published |
| Part 2 | Creating Custom Generators with ng generate | ✅ Published |
| Part 3 | Building Installation Schematics with ng add | ✅ Published |
| Part 4 | Writing Migration Schematics with ng update | ✅ You are here |
| Part 5 | Testing Schematics with Angular DevKit | ? Coming Soon |
| Part 6 | Advanced Patterns, Publishing & Nx Integration | ? Coming Soon |
Created using Angular v21 · @angular-devkit/schematics · @angular-devkit/core
