In my previous post I walked through the three features I brought to npm v12. The first of them, native dependency patching with npm patch, replaces a job that most npm projects have handed to patch-package for years. This post is the practical follow-up: how to move an existing patch-package setup to npm’s native patching, including a script that converts your existing patch files for you.
Every command and output below was run against npm 12.0.2 and patch-package 8.0.1 on a real project before I wrote it down.
Why migrate at all?
patch-package has served the ecosystem well, and if you’re still on npm v11 there’s no reason to rip it out. On npm v12, though, three things change the picture.
Creating new patches with patch-package fails under npm v12 defaults. To build a patch, patch-package installs a clean copy of the package in a temp directory, and for npm projects it installs it from the tarball URL recorded in your lockfile. npm v12 blocks remote tarball URLs by default via allow-remote, so that inner install fails:
$ npx patch-package is-odd patch-package 8.0.1 • Creating temporary folder • Installing [email protected] with npm { status: 1, ... }
patch-package hides npm’s output, but replaying the same install by hand shows the cause:
npm error code EALLOWREMOTE npm error Fetching packages of type "remote" have been disabled npm error Refusing to fetch "is-odd@https://registry.npmjs.org/is-odd/-/is-odd-3.0.1.tgz"
You can work around it with npm_config_allow_remote=all npx patch-package <pkg>, but that means loosening a security default every time you touch a patch. Applying existing patches is unaffected: npm v12 denies install scripts from your dependencies, but your own project’s postinstall still runs.
patch-package is a postinstall script, so anything that skips scripts skips your patches. npm ci --ignore-scripts is common in CI and Docker builds, and with patch-package it silently gives you unpatched code:
$ npm ci --ignore-scripts
$ node -e "require('is-odd')(1.5)"
Error: expected an integer # the patch never ran
Native patches are applied by the installer itself, so --ignore-scripts has no effect on them.
patch-package can’t see transitive dependencies under isolated mode. With install-strategy=linked, direct dependencies are symlinks into node_modules/.store/, so patch-package still manages to patch those. A nested dependency lives only inside the store, though, and patch-package can’t find it. Worse, the install still exits 0:
Applying patches... [email protected] ✔ Error: Patch file found for package is-number which is not present at node_modules/is-odd/node_modules/is-number --- patch-package finished with 1 error(s). added 61 packages, and audited 62 packages in 821ms
I’ve opened ds300/patch-package#596 to teach patch-package the .store layout (#595), and it’s still open at the time of writing, so on npm v12 the native route is the one that works today.
Beyond those, native patching gives you a few things patch-package can’t:
- The patch’s hash is recorded in
package-lock.json, sonpm cirejects a patch file that changed without the lockfile being updated. - A patch that no longer applies, or that matches nothing installed, fails the install instead of logging a warning.
npm lsshows which installed packages are patched.npm patch updaterebases a patch onto a new version of the package with a 3-way merge.
What’s different
patch-package | npm native | |
|---|---|---|
| Where patches are declared | Implied by file names in patches/ | patchedDependencies in the root package.json |
| File name | @scope+name+1.2.3.patch | patches/@scope/[email protected] |
| Paths inside the diff | a/node_modules/<pkg>/index.js | a/index.js (relative to the package root) |
| When patches apply | postinstall script | During install, right after each package is extracted |
| Lockfile | Not involved | Patch hash recorded in package-lock.json |
| Nested copies | parent++child+1.2.3.patch targets one location | [email protected] applies to every installed copy of that version |
| Several patches per package | --append sequences | One patch per selector (the migration merges them) |
| Dev-only patches | .dev.patch suffix | Not needed: patches for omitted packages are skipped |
Two practical consequences:
- You can’t point
patchedDependenciesat your old files. Becausepatch-package‘s diff paths includenode_modules/<pkg>/, npm looks fornode_modules/is-odd/index.jsinside the package and fails:npm error code EPATCHFAILED npm error file node_modules/is-odd/index.js npm error patch target is missing: node_modules/is-odd/index.jsThe files need to be regenerated, which is what the steps below do. - Your lockfile moves to
lockfileVersion4. The first time you add a patch, npm tells you it’s upgrading the lockfile:npm warn shrinkwrap patchedDependencies requires lockfileVersion 4; upgrading the lockfile from version 3.Make sure everyone, CI included, is on npm v12 from that point on. I rannpm cion the migrated project with npm 11: it exited 0 and installed unpatched code, because older npm simply ignorespatchedDependencies. To turn that into a hard error, add adevEnginesguard:npm pkg set devEngines.packageManager.name=npm \ 'devEngines.packageManager.version=>=12' \ devEngines.packageManager.onFail=errorWith that in place, npm 11 stops withEBADDEVENGINESinstead of installing.
Migrating, step by step
I’ll use a small project with the kinds of patches you’d find in a real repo: a scoped package, a package with two --appended patches, and a nested dependency:
patches/ ├── @sindresorhus+slugify+2.2.1.patch ├── is-odd++is-number+6.0.0.patch ├── is-odd+3.0.1+001+initial.patch └── is-odd+3.0.1+002+second.patch
1. Remove patch-package
Start from a clean install, then drop the dependency and the postinstall hook:
npm ci npm uninstall patch-package npm pkg delete scripts.postinstall
If your postinstall runs other things too ("postinstall": "patch-package && husky"), edit it by hand and keep the rest.
2. Convert one patch by hand
For a single package, the conversion is three commands. npm patch add extracts a pristine copy of the package into a temp directory and prints its path:
$ npm patch add is-odd You can now edit the following directory: /var/folders/.../npm-patch/[email protected] When done, run: npm patch commit /var/folders/.../npm-patch/[email protected]
Instead of editing by hand, apply the old patch into that directory with git apply. The -p flag strips the leading path segments: -p3 removes a/node_modules/is-odd/ from an unscoped package, -p4 removes a/node_modules/@scope/name/ from a scoped one.
git -C /var/folders/.../npm-patch/[email protected] apply -p3 "$PWD/patches/is-odd+3.0.1.patch" npm patch commit /var/folders/.../npm-patch/[email protected]
Patched [email protected] -> patches/[email protected]
npm patch commit diffs the directory against the original tarball, writes the new patch, adds it to patchedDependencies, and updates the lockfile. Then delete the old is-odd+3.0.1.patch.
3. Or convert them all with a script
For more than a couple of patches, this script does the same thing for every patch-package file. It groups --appended patches for the same package and applies them in order into one edit directory, works out the -p depth for scoped and nested patches, and removes the old files once each package is committed. It uses git and runs on macOS and Linux.
// migrate-patches.mjs
/**
* Convert patch-package patches into npm native patches.
* Usage: node migrate-patches.mjs [patch-package-dir]
*/
import { execFileSync } from 'node:child_process'
import { mkdtempSync, readdirSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
const dir = resolve(process.argv[2] || 'patches')
const npm = (...args) => execFileSync('npm', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] })
// Parse "@scope+name+1.2.3+001+label.patch" or "parent++child+1.2.3.patch".
const parse = file => {
const chain = file.replace(/(\.dev)?\.patch$/, '').split('++').map(part => {
const bits = part.split('+')
const v = bits.findIndex(b => /^\d+\.\d+\.\d+/.test(b))
const name = (v === -1 ? bits : bits.slice(0, v)).join('/')
return { name, version: bits[v], seq: Number(bits[v + 1] || 0) }
})
const { name, version, seq } = chain.at(-1)
// Strip "a/" plus every segment of node_modules/<parent>/node_modules/<name>.
const strip = 1 + chain.reduce((n, p) => n + 1 + p.name.split('/').length, 0)
return { file, name, version, seq, strip }
}
const groups = new Map()
for (const file of readdirSync(dir).filter(f => f.endsWith('.patch') && f.includes('+'))) {
const p = parse(file)
const key = `${p.name}@${p.version}`
groups.set(key, [...(groups.get(key) || []), p])
}
for (const [key, patches] of groups) {
const editDir = mkdtempSync(join(tmpdir(), 'npm-patch-migrate-'))
npm('patch', 'add', key, '--edit-dir', editDir)
for (const p of patches.sort((a, b) => a.seq - b.seq)) {
execFileSync('git', ['apply', `-p${p.strip}`, join(dir, p.file)], { cwd: editDir, stdio: 'inherit' })
}
process.stdout.write(npm('patch', 'commit', editDir))
for (const p of patches) {
rmSync(join(dir, p.file))
}
}
Run it from the project root (pass the directory if you used patch-package‘s --patch-dir):
$ node migrate-patches.mjs npm warn shrinkwrap patchedDependencies requires lockfileVersion 4; upgrading the lockfile from version 3. Patched @sindresorhus/[email protected] -> patches/@sindresorhus/[email protected] Patched [email protected] -> patches/[email protected] Patched [email protected] -> patches/[email protected]
The two is-odd patches are now a single patches/[email protected], and package.json has:
"patchedDependencies": {
"@sindresorhus/[email protected]": "patches/@sindresorhus/[email protected]",
"[email protected]": "patches/[email protected]",
"[email protected]": "patches/[email protected]"
}
If a patch no longer applies (patch-package tolerates a version mismatch with a warning, npm does not), git apply stops the script at that package. Fix the patch or bump the dependency, then run the script again: packages that already converted are gone from the old naming scheme, so it picks up where it left off.
4. Verify and commit
Do a clean install and check what’s patched:
$ rm -rf node_modules && npm ci $ npm ls --all | grep patched ├─┬ @sindresorhus/[email protected] [patched: patches/@sindresorhus/[email protected]] └─┬ [email protected] [patched: patches/[email protected]] └── [email protected] [patched: patches/[email protected]] $ npm patch ls patches/@sindresorhus/[email protected] @sindresorhus/[email protected] (1 node) patches/[email protected] [email protected] (1 node) patches/[email protected] [email protected] (1 node)
Run your test suite, then commit package.json, package-lock.json, and the patches/ directory together. A patch file without its lockfile entry is exactly what npm ci is designed to reject.
Day-to-day with native patches
Making a new patch is the same add, edit, commit loop, no allow-remote workaround required:
npm patch add lodash # edit the files in the printed directory npm patch commit <printed directory>
If more than one version is installed, npm asks you to pick with an exact selector like npm patch add [email protected].
Upgrading a patched package is where the strictness pays off. Bump the dependency without touching the patch, and the install stops instead of quietly shipping unpatched code:
$ npm install @sindresorhus/[email protected] npm error code EPATCHUNUSED npm error The following patches were registered but matched no installed package: npm error @sindresorhus/[email protected] -> patches/@sindresorhus/[email protected] npm error Use --allow-unused-patches to install anyway.
Rebase the patch first, then install the new version:
$ npm patch update @sindresorhus/slugify --to 3.0.1 Updated @sindresorhus/[email protected] -> @sindresorhus/[email protected] (patches/@sindresorhus/[email protected]) $ npm install @sindresorhus/[email protected]
npm patch update replays your patch onto the new version with a 3-way merge. If it conflicts, it leaves the merged files in an edit directory for you to resolve and finish with npm patch commit.
Dropping a patch once upstream ships the fix:
$ npm patch rm is-number Removed patch: [email protected]
That removes the entry, deletes the patch file, and reinstalls the pristine package.
Tampering is caught. Edit a patch file by hand without reinstalling and npm ci refuses to proceed:
npm error Invalid: patch for [email protected] does not match the patch recorded in the lock file
Run npm install after an intentional edit to record the new hash.
Things to know
- Registry packages only.
file:, git, and remote tarball dependencies can’t be patched (EPATCHNONREGISTRY); edit those at the source. - Root
package.jsononly. In a monorepo,patchedDependenciesbelongs in the root manifest, not a workspace’s. - Text files only. Patches are text diffs, so changes to binary files (images, wasm, native addons) can’t be captured.
package.jsonedits aren’t captured. To fix a dependency’s manifest, usepackageExtensionsinstead.- Custom location. Set
patches-dirif you want patches somewhere other thanpatches/. - Escape hatches are CLI-only.
--allow-unused-patchesand--ignore-patch-failuresare ignored in.npmrcand rejected bynpm ci, so a relaxed setting can’t quietly leak into CI.
Conclusion
For most projects, the migration is one script run and one commit: uninstall patch-package, convert the patch files, verify with npm ci, done. What you get in return is patching that survives --ignore-scripts, works under isolated mode, can’t drift from the lockfile, and tells you loudly when an upgrade breaks a patch.
If you’re coming from another package manager, the model will feel familiar: it’s the same idea as pnpm patch and yarn patch. The implementation landed in npm/cli#9439, and if you run into an edge case during your migration, please open an issue. That’s how the rough edges get found.