What is nx migrate and how does its two-phase upgrade work?

nx migrate is the upgrade mechanism built into Nx. It moves a workspace from one Nx version to another and applies the code changes that each version's plugins require. It splits this work into two phases that you run separately.

Phase one: plan. Running nx migrate latest, or nx migrate <package>@<version> for a specific target, resolves the target versions of Nx and its related packages. It then rewrites the version ranges in package.json and writes a migrations.json file listing every migration that applies between your current and target versions. It does not touch your source code, project configuration or lockfile. You still have to install dependencies yourself.

Phase two: apply. nx migrate --run-migrations reads migrations.json and executes each entry against the workspace. This is the only step that changes source files and configuration.

The split exists because the two phases carry very different risk. Generating the plan is cheap and easy to reverse: you can inspect the version bumps, review the migration list and discard the result. Running the plan is an automated refactor across the whole codebase. It deserves its own branch, staged execution and verification before merge.

Treat migrations as best-effort codemods. They handle the patterns plugin authors expected to find. They can miss nonstandard configuration, custom executors and files that people have edited by hand. A clean run means nothing threw an error. It does not prove the result is correct.

This guide is for backend and platform engineers who own monorepo tooling and need Nx version updates to be repeatable rather than heroic.

# Phase one: plan. Do it on a dedicated branch so the result is easy to discard.
git checkout -b chore/nx-upgrade

# Resolve target versions, rewrite package.json, write migrations.json.
# No source, project config or lockfile changes happen here.
npx nx migrate latest
# Or pin an explicit target instead:
# npx nx migrate nx@<version>

# Review the plan before anything else runs.
git diff package.json
cat migrations.json

# Optional: commit the plan by itself so it can be reviewed and reverted alone.
git add package.json migrations.json
git commit -m "chore: nx migrate plan"

# Not happy with the plan? Throw it away. Nothing else was touched.
# git checkout -- package.json && rm migrations.json

# Install the new versions yourself. nx migrate does not do this.
npm install

# Phase two: apply. This is the only step that edits source and config.
npx nx migrate --run-migrations

# Read every change the codemods made. Look closely at custom executors
# and hand-edited config files, which migrations are most likely to miss.
git status
git diff

# A clean migration run only means nothing threw. Verify the result.
npx nx run-many -t lint test build

A two-phase nx migrate run: generate and review the plan on its own branch, install dependencies, apply the migrations, then inspect the diff and run lint, test and build to verify the result.

What does migrations.json contain and how do plugins ship migrations?

After the first phase, the root migrations.json holds a migrations array. Each entry names the package that owns it, a migration name, the version that introduced it, and a description. It usually also points to an implementation. Read the file before running anything. The package and description tell you which area of the workspace the migration targets, such as project configuration, a framework's config files, or the TypeScript base config. That tells you where to look in the diff afterward. If a description is vague, open the implementation inside node_modules and read it.

You can delete or reorder entries to skip or defer a migration. Do this deliberately and record why in the commit message. Skipping one creates drift that nothing tracks. Later migrations are written against the shape the earlier ones would have produced, so they may fail or quietly do the wrong thing.

On the plugin side, the plugin's package.json declares an nx-migrations field that points to its migrations file. That file lists migration entries, each keyed to a version and an implementation. Each implementation works like a generator: it receives the virtual Tree, reads and writes files there, and changes reach disk only after it finishes. The file can also define packageJsonUpdates, which bump related dependencies together.

These pieces are a reasonable way to judge a third-party plugin's quality:

  • Does it ship migrations at all?
  • Are the descriptions specific about what they change?
  • Does it keep its peer dependencies aligned through packageJsonUpdates?
  • Does it leave every config change for you to make by hand?
// packages/nx-plugin/src/migrations/update-2-0-0/rename-config-option.ts
// Registered in the plugin's migrations.json under "generators" with
// "version": "2.0.0" and a specific description, so users can predict
// that only project.json targets using this executor will change.
import {
  Tree,
  formatFiles,
  getProjects,
  updateProjectConfiguration,
} from '@nx/devkit';

export default async function renameConfigOption(tree: Tree) {
  // All reads and writes go through the virtual Tree. Nothing reaches
  // disk until nx migrate --run-migrations finishes this function.
  for (const [name, project] of getProjects(tree)) {
    let changed = false;

    for (const target of Object.values(project.targets ?? {})) {
      // Touch only targets owned by this plugin; leave everything else alone.
      if (target.executor !== '@acme/nx-plugin:build') continue;

      // Assumes the 1.x shape: `configFile` is renamed to `config` in 2.0.0.
      // If an earlier migration was skipped, this shape may not hold.
      if (target.options?.configFile !== undefined) {
        target.options.config = target.options.configFile;
        delete target.options.configFile;
        changed = true;
      }
    }

    // Write back only what changed, which keeps the diff small and reviewable.
    if (changed) updateProjectConfiguration(tree, name, project);
  }

  // Formatting the touched files keeps the migration's diff limited to real edits.
  await formatFiles(tree);
}

A minimal plugin migration written as a generator-style function: it edits project configuration on the virtual Tree, assumes the shape left by earlier versions, and changes only the targets that use the plugin's own executor.

How do you run nx migrate safely, one step at a time?

Start on a dedicated branch from a clean working tree. If uncommitted work is mixed into the upgrade, you cannot tell what the migration changed and what you changed, and git restore stops being a safe undo.

The workflow has three steps:

  1. Run nx migrate with your target version. This only rewrites package.json and writes migrations.json. Commit this on its own.
  2. Install dependencies with your package manager. The migrations run from the newly installed plugin packages, so skipping this step executes the old code.
  3. Run nx migrate --run-migrations, then commit the generated source and config changes separately.

Splitting the version bump from the generated code lets reviewers see which diff was automated and which lines need a careful look.

Move one major version per hop and run migrations after each one. Migrations are written against the previous major. When you jump several majors at once, migrations that assume intermediate states pile up in one run, and a failure is harder to attribute. The cost is time: each hop needs its own install, migration run, and CI pass.

Nx can also create one commit per migration. Run nx migrate --help on your installed version to confirm the option exists before you put it in a runbook. Per-migration commits make git bisect precise, but they add noise to history. Some intermediate commits may not build either, so bisect will need to skip them.

After a successful run, delete migrations.json so nobody re-executes it by accident.

#!/usr/bin/env bash
# One major-version hop of an Nx upgrade. Run once per hop.
# Usage: ./nx-hop.sh 20.0.0   (target version is a placeholder)
set -euo pipefail
TARGET="$1"

# Refuse to start on a dirty tree so every diff comes from the upgrade.
if [ -n "$(git status --porcelain)" ]; then
  echo "Working tree is not clean; commit or stash first." >&2
  exit 1
fi
git switch -c "chore/nx-${TARGET}"

# Step 1: rewrite package.json and write migrations.json. No source changes yet.
npx nx migrate "$TARGET"
git add package.json migrations.json
git commit -m "chore: bump Nx to ${TARGET}"

# Step 2: install so the migrations run from the NEW plugin code.
npm install
git add package-lock.json
git commit -m "chore: update lockfile for Nx ${TARGET}"

# Optional: see whether your version offers per-migration commits
# before relying on it in a runbook.
npx nx migrate --help | grep -i commit || true

# Step 3: apply the generated code and config changes as a separate commit.
npx nx migrate --run-migrations
git add -A
git commit -m "chore: apply Nx ${TARGET} migrations"

# Remove migrations.json so it is not re-run by accident.
git rm migrations.json
git commit -m "chore: remove applied migrations.json"

A script for one major-version Nx upgrade hop. It checks for a clean tree, commits the version bump apart from the migration-generated changes, checks whether per-migration commits are supported, and removes migrations.json at the end.

How should you verify and recover after an nx upgrade?

Start with nx reset. It clears the local computation cache and stops the daemon, so the daemon cannot serve a project graph computed before the upgrade, and cached task outputs cannot hide a broken executor. The first runs afterwards are slower because nothing is cached.

Then run lint, test, build and e2e with nx run-many across every project rather than nx affected. Migrations often rewrite shared configuration such as target defaults, base tsconfig paths and plugin options. Affected detection works from file changes mapped to projects, and it can underestimate the impact of a global config change. On a large monorepo a full run can take a long time, so schedule it on CI with distributed execution if you have it, but do not skip it.

Review the diff by hand, focusing on these files:

File What to check
nx.json Changed target defaults, named inputs, plugin registrations
project.json Renamed executors, moved or dropped options
tsconfig*.json Altered paths, module or target settings

Recovering from a bad migration

If a migration produces broken output, reset to the commit before the run. Remove the offending entry from migrations.json, or edit it, then rerun with nx migrate --run-migrations. Apply the skipped change by hand where you can control it. Record the deviation in the pull request description or a tracked issue. Otherwise the workspace drifts from what later migrations expect, and nobody will remember why.

#!/usr/bin/env bash
set -euo pipefail

# --- Verify after `nx migrate --run-migrations` ---

# Clear the local cache and stop the daemon so nothing pre-upgrade is reused.
npx nx reset

# Run every target on every project. Shared config changes can slip past affected detection.
npx nx run-many -t lint test build e2e

# Review the config files that migrations most often rewrite.
git diff pre-migrate-run -- nx.json '**/project.json' '**/tsconfig*.json'

# --- Recover from a bad migration ---
# Assumes you committed package.json, the lockfile and migrations.json after
# `nx migrate latest` and the install, then tagged that commit before running migrations:
#   git tag pre-migrate-run

# Discard everything the migration run wrote to tracked files.
git reset --hard pre-migrate-run

# Drop the offending entry by name (placeholder name shown), or edit the file by hand.
jq 'del(.migrations[] | select(.name == "update-example-target-defaults"))' \
  migrations.json > migrations.tmp.json
mv migrations.tmp.json migrations.json

# Rerun the remaining migrations.
npx nx migrate --run-migrations

# Apply the skipped change by hand, then record the deviation in the commit.
git add -A
git commit -m "chore: apply nx migrations" \
  -m "Skipped update-example-target-defaults and applied the change manually. See the PR description."

A shell sequence that resets Nx state, runs every target across all projects, diffs the key config files, and recovers from a bad migration by resetting to a tagged pre-run commit, removing one migrations.json entry and rerunning the rest.

How do you upgrade first-party and third-party Nx plugins together?

Official Nx packages (nx itself and the @nx/* plugins) are released together and expect to run at the same version. They share @nx/devkit internals, project graph formats and executor contracts, so a workspace with nx at one version and @nx/js at another can fail in confusing ways: generators that call APIs that no longer exist, or two copies of the devkit resolving in node_modules. nx migrate bumps every official package it knows about in one pass. Don't override individual @nx/* versions in package.json afterwards.

Community plugins are not part of that release train. Before you start, check each one. Look at the peerDependencies it declares on nx or @nx/devkit in its latest published release, and read its changelog or issue tracker for reports against your target version. A plugin that declares a compatible peer range has at least claimed support. One that hasn't published since your current Nx version is a risk.

When a plugin lags, you have three options:

Option Effort now Ongoing cost Risk
Pin the older Nx version None Grows with every Nx release you skip; later jumps get larger Missed fixes and security patches
Fork the plugin Moderate: patch and publish internally You own every future Nx compatibility fix Fork diverges from upstream
Remove the plugin Varies: replace targets with nx:run-commands or official plugins Low once done Lost features or caching behaviour

Pinning is cheap only for a short time. Forking is justified when the plugin is central to your workspace and upstream is responsive enough that you can eventually return to it. In practice, the slowest third-party plugin you depend on often decides how fast the whole workspace can move. Factor that in when you choose plugins, not only when you upgrade.

How do you write migrations for internal workspace plugins?

Internal plugins use the same mechanism as published ones. In the plugin's package.json, point the nx-migrations field at a migrations file. In that file, add an entry under generators with a version, a description, and an implementation path. nx migrate compares the version against the installed and target plugin versions to decide whether the migration runs. Set it to the plugin release that introduces the breaking change. Do not reuse the version of an earlier release.

Write the implementation as a function that receives the Tree and changes it only when the old state is still present. Suppose you are renaming a target. Read each project configuration and skip any project that already has the new target name or lacks the old one. Then rename and call updateProjectConfiguration. Use updateJson for config files instead of string replacement, and finish with formatFiles. This matters because a developer who hits a failure halfway through will often discard some changes and run nx migrate --run-migrations again. A migration that blindly appends or renames will then duplicate entries or delete the new target.

For tests, build a fixture with createTreeWithEmptyWorkspace from @nx/devkit/testing. Add projects in both the old and the already-migrated state, run the migration twice, and assert that the second run produces no further changes.

Version and publish the migration with the plugin itself, so downstream repositories pick it up through their normal nx migrate flow. The trade-off is effort. Idempotent migrations need more guard logic and more fixture cases, but a non-idempotent migration is only safe if nobody ever reruns it, and you can't count on that.

When should you avoid nx migrate, and what are the alternatives?

nx migrate pays off when the upgrade is routine: a regular Nx version update, or a plugin bump whose codemods rewrite config and imports you would otherwise edit by hand in dozens of projects. It costs more than it saves in three situations. During a release freeze, any lockfile or config churn adds risk to a branch that should stay stable. When a plugin you depend on has not published a compatible release, the upgrade blocks until that plugin catches up or you replace it. In a heavily customized workspace, with forked executors, generated config, or nonstandard project layouts, codemods built for the common case can misfire and produce edits that look correct but are wrong.

Approach Best for Main cost
Staged nx migrate, one major at a time Most workspaces More CI runs and review rounds
Single multi-major jump Small, standard workspaces Failures are harder to isolate
Manual package.json bump plus hand edits Workspaces where codemods misfire Engineers redo work the codemods would have done
Pin the current version Release freezes, blocked plugins Upgrade debt grows over time

Illustrative estimate. Assume 40 projects, each needing about 10 minutes of manual config edits per major. That comes to roughly 6.5 hours of hand edits. Staging three majors adds perhaps three full CI runs and three reviews. If one CI run and one review together take an hour, the codemods still save time. If your CI takes much longer per run, recalculate before you choose.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nx migrate, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.