How to check environment parity

Parity is a diff of two fingerprints, not a feeling. Print the Node and npm versions, the lockfile hash and every installed dependency version on each environment, then diff the two files. Here two environments whose package.json parses to the same object disagreed on Node, on npm and on semver 7.6.0 against 7.8.5.

Why check this

This page covers runtime and dependency parity: which Node and npm are running, which package versions are on disk, and whether both sides came from the same lockfile. The key by key comparison of .env and config files is a separate check, and it is linked below.

Run this before a staging sign-off is treated as evidence about production, and after any deploy that reinstalled dependencies rather than shipping a built artefact. The failure it prevents is the sign-off that proves nothing: staging passes on one minor version of a dependency, production runs another, and the defect belongs to a version nobody compared.

Prerequisites

import { createHash } from 'node:crypto';
import { readFileSync, existsSync } from 'node:fs';
import { execSync } from 'node:child_process';

const out = [];
out.push('runtime.node        ' + process.version);
out.push('runtime.npm         ' + execSync('npm -v').toString().trim());
out.push('runtime.platform    ' + process.platform + '-' + process.arch);

const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
out.push('engines.node        ' + (pkg.engines?.node ?? '(absent)'));

if (existsSync('package-lock.json')) {
  const raw = readFileSync('package-lock.json');
  out.push('lock.version        ' + JSON.parse(raw).lockfileVersion);
  out.push('lock.sha256         ' + createHash('sha256').update(raw).digest('hex').slice(0, 16));
} else {
  out.push('lock.sha256         (absent)');
}

for (const name of Object.keys(pkg.dependencies ?? {}).sort()) {
  const p = 'node_modules/' + name + '/package.json';
  const v = existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')).version : 'NOT INSTALLED';
  out.push('dep.' + name.padEnd(16) + v);
}
console.log(out.join('\n'));

Steps

  1. Step 1.

    Fingerprint the first environment. Run it from the application root, where package.json and node_modules are.

    node env-fingerprint.mjs > staging.txt && cat staging.txt
    
    runtime.node        v20.19.6
    runtime.npm         10.8.2
    runtime.platform    win32-x64
    engines.node        >=20
    lock.version        3
    lock.sha256         3024fcdccba61f63
    dep.ms              2.1.3
    dep.semver          7.6.0

    dep. lines read the version from inside node_modules, so they report what is installed rather than what is declared.

  2. Step 2.

    Fingerprint the second environment with the same file.

    node env-fingerprint.mjs > production.txt && cat production.txt
    
    runtime.node        v22.23.2
    runtime.npm         10.9.8
    runtime.platform    win32-x64
    engines.node        >=20
    lock.version        3
    lock.sha256         dab0c151fa0c4cea
    dep.ms              2.1.3
    dep.semver          7.8.5

    engines.node reads >=20 on both. The declared requirement is satisfied in both places and says nothing about parity.

  3. Step 3.

    Compare the two files.

    diff staging.txt production.txt
    
    1,2c1,2
    < runtime.node        v20.19.6
    < runtime.npm         10.8.2
    ---
    > runtime.node        v22.23.2
    > runtime.npm         10.9.8
    6c6
    < lock.sha256         3024fcdccba61f63
    ---
    > lock.sha256         dab0c151fa0c4cea
    8c8
    < dep.semver          7.6.0
    ---
    > dep.semver          7.8.5

    Four lines in three groups: a runtime difference, a lockfile difference, and one dependency two minors apart. ms and the platform match, so those rows are absent.

  4. Step 4.

    Rule out the explanation everyone reaches for first, that the two sides declare different dependencies.

    diff <(node -p "JSON.stringify(require('../env-staging/package.json'))") <(node -p "JSON.stringify(require('./package.json'))") && echo "package.json is the same object in both"
    
    package.json is the same object in both

    Compare the parsed objects rather than the files. npm rewrites whitespace in the manifest when it installs, so a byte comparison reports a difference that means nothing.

  5. Step 5.

    Reinstall the second environment from the first one's lockfile, then fingerprint it again and diff.

    npm ci --silent && node env-fingerprint.mjs > rebuilt.txt && diff staging.txt rebuilt.txt
    
    1,2c1,2
    < runtime.node        v20.19.6
    < runtime.npm         10.8.2
    ---
    > runtime.node        v22.23.2
    > runtime.npm         10.9.8

    Every dependency line now matches, and the lockfile hash with them. What is left is the runtime, which a lockfile cannot fix and a version manager has to.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | runtime.node differs | Two Node versions | Pin both from one file and read How to check node version of a project. | | lock.sha256 differs | The two sides installed from different lockfiles | Ship the lockfile with the deploy and install with npm ci. | | A dep. line differs, lockfile hash matches | Something wrote to node_modules after the install | Reinstall from scratch. A patched module in place is a defect that travels. | | NOT INSTALLED on a dep. line | Declared and absent | The install failed or was partial. Nothing else on this page is trustworthy until it is fixed. | | Only runtime.platform differs | Different OS or architecture | Expect native modules to be rebuilt, and treat performance numbers from one side as not comparable. |

Common mistakes

Sign: Parity is declared because both environments have the same package.json.Cause: The manifest states ranges. Two installs of the same ^7.6.0 range produced 7.6.0 and 7.8.5 here, from a manifest that parses to the same object on both sides. What the environments have in common is the intention, not the tree.
Sign: The engines range is used as evidence that the runtimes agree.Cause: Both environments above declare node >=20 and both satisfy it, on 20.19.6 and on 22.23.2. A range that admits two majors is a compatibility statement, not a parity statement. Compare process.version, which is the running fact.
Sign: A byte comparison of package.json reports a difference nobody introduced.Cause: npm reformats the manifest when it writes to it, so installing one package with an explicit version can reindent the file. The parsed objects were identical here while the two files were not. Compare JSON.stringify of both, as in step 4.

What to check next

FAQ

How do I check environment parity between staging and production?

Run one fingerprint script in each, redirect to a file, and diff the two files. Steps 1 to 3 do that. Keep the output sorted and one fact per line, so the diff names the difference instead of moving whole blocks around.

Is this the same as comparing environment variables?

No. This page compares runtimes and installed packages. Key by key comparison of configuration belongs to the config drift check linked above. Running both gives the full answer, and they fail for different reasons.

Why compare the lockfile hash rather than the lockfile?

A lockfile is thousands of lines and a diff of two of them is unreadable. The first 16 hex characters of a sha256 answer the only question a parity check has: same file or not. Diff the lockfiles themselves once the hashes disagree.

Does npm ci guarantee parity?

It guarantees the dependency tree, and nothing above it. After npm ci from the same lockfile, every dep. line matched here and the Node and npm rows still differed. Runtime parity needs a pinned version, not an install command.

What else belongs in a fingerprint?

The container image digest, the OS release, the timezone, the locale and the commit hash of the deployed code. Add one line each, keep the format stable, and store the output with the release so a later comparison has something to compare against.

Verified

Verified by Maks Vernynode 22.23.2 and 20.19.6npm 10.9.8 and 10.8.2GNU diffutils 3.10

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate9 minpublished updated Maks Verny