In the previous lesson you installed dotenv and npm wrote ^17.2.1 in the manifest, not 17.2.1. That caret is a decision with enormous consequences: it means your project is not asking for a version, it is asking for a range, and the specific version that ends up installed depends on what dotenv's author has published by the day someone runs npm install.

That is either a time bomb or an extraordinary convenience, depending on how you understand it. Without package-lock.json, two people on the same team can install the same package.json on different days and end up with different dependency trees. With it, the install is reproducible down to the last byte.

This lesson has two halves that explain each other: the versioning system that promises compatibility and the file that makes that promise unnecessary.

Contents

  1. SemVer: MAJOR.MINOR.PATCH and the implicit contract
  2. Pre-release versions and their precedence order
  3. The range table, with the row almost nobody knows
  4. What each range installs today and six months from now
  5. package-lock.json: what it contains exactly
  6. npm ci versus npm install
  7. Lock conflicts in git
  8. npm dedupe and --save-exact
  9. The minor update that breaks things
  10. From 0.1.0 to 1.0.0: versioning Escena Viva

  1. SemVer: MAJOR.MINOR.PATCH and the implicit contract

Semantic versioning (semver.org) is a convention that turns three numbers into a verifiable promise:

    17  .   2   .   1
     │       │       │
     │       │       └── PATCH: compatible fixes
     │       └────────── MINOR: new functionality, compatible
     └────────────────── MAJOR: changes that break compatibility

The rules for when each number goes up:

Type of change Bumps Example
Fixing a bug without changing the API PATCH 17.2.1 → 17.2.2
Adding a new function or option MINOR 17.2.1 → 17.3.0
Marking something deprecated (without removing it) MINOR 17.2.1 → 17.3.0
Removing or renaming something public MAJOR 17.2.1 → 18.0.0
Changing the behavior of an existing function MAJOR 17.2.1 → 18.0.0
Raising the minimum required Node version MAJOR 17.2.1 → 18.0.0
Changing comments, the README or internal tests Nothing Not published

When a number goes up, everything to its right resets to zero: from 17.2.1, a minor bump gives 17.3.0, and a major bump gives 18.0.0.

The implicit contract is the part people forget. Publishing 17.3.0 instead of 18.0.0 is not an aesthetic preference: it is a statement to thousands of projects that they can upgrade without reviewing their code. And since 90% of those projects have ^17.2.1 written down, that minor version will enter their installs automatically. If you lied, their builds break tomorrow without them having changed a line.

That is why semver is, before it is a technical rule, a matter of responsibility. And it is also why you cannot fully trust it: it is a convention, not something the registry verifies. What does get verified is the lockfile, and that is why the two halves of this lesson belong together.

  1. Pre-release versions and their precedence order

Before a stable version, test versions are published with a hyphen:

1.0.0-alpha
1.0.0-alpha.1
1.0.0-beta.1
1.0.0-rc.1
1.0.0

The precedence rules, as defined by the specification: a version with a pre-release tag is always lower than the same version without one (1.0.0-rc.1 < 1.0.0, because it is a rehearsal for that version, not a later one); identifiers are compared left to right; numeric ones as numbers and alphanumeric ones alphabetically; a number is always lower than text (1.0.0-1 < 1.0.0-alpha); and when everything above ties, whoever has more identifiers wins (1.0.0-alpha < 1.0.0-alpha.1).

A complete ordered series:

1.0.0-alpha  <  1.0.0-alpha.1  <  1.0.0-alpha.beta  <  1.0.0-beta
             <  1.0.0-beta.2   <  1.0.0-beta.11     <  1.0.0-rc.1  <  1.0.0

Notice beta.2 < beta.11: because the identifier is numeric it is compared as a number, not as text. If it were compared as text, 11 would come before 2.

The most important part day to day: pre-release versions never enter a normal range. If your manifest says ^1.0.0 and 2.0.0-beta.1 is published, npm will not install it. To receive them you have to ask explicitly (npm install package@beta) or write a range that includes them. It is a deliberate protection, and it is the right one.

  1. The range table, with the row almost nobody knows

This is the table to know by heart. Suppose the latest published version is 1.9.2 and that 2.0.0 and 1.2.9 also exist.

Range in package.json Name Allows Installs today
1.2.3 Exact Only 1.2.3 1.2.3
~1.2.3 Tilde Patches: >=1.2.3 <1.3.0 1.2.9
^1.2.3 Caret Minors and patches: >=1.2.3 <2.0.0 1.9.2
1.2.x / 1.2 Partial wildcard Same as ~1.2.0 1.2.9
1.x / 1 Partial wildcard Same as ^1.0.0 1.9.2
>=1.2.3 <2 Explicit range What it says 1.9.2
1.2.3 - 1.5.0 Hyphen Both ends included 1.5.0
* or "" Anything Any version 2.0.0
latest Tag Whatever is tagged latest 2.0.0

The short way to remember the two important ones: ~ lets the last number rise; ^ lets the last non-zero-from-the-left one rise.

The row almost nobody knows: ^0.x.y

Here is the detail that produces unpleasant surprises. The caret is defined as "do not change the first non-zero number", and that gives different behavior when the major is 0:

Range Equivalent to Comment
^1.2.3 >=1.2.3 <2.0.0 The usual one
^0.2.3 >=0.2.3 <0.3.0 Patches only: 0.2 acts as if it were the major
^0.0.3 >=0.0.3 <0.0.4 That version only: equivalent to pinning it

The logic is sound: in 0.x the library declares that its API is still moving, so semver treats every minor version as potentially incompatible. The practical consequence is surprising though: an npm update moves nothing in a 0.x package apart from patches. If you expected to go from 0.2.9 to 0.4.0 automatically, it will not happen; you have to change the range by hand.

And the corollary for when you publish: while you are on 0.x you promise nothing, and whoever uses you knows it. That is precisely why Escena Viva started at 0.1.0 and not at 1.0.0.

  1. What each range installs today and six months from now

The thought experiment that clarifies the problem. Today, with [email protected] as the latest published version:

Range Installs today
17.2.1 17.2.1
~17.2.1 17.2.1
^17.2.1 17.2.1

All three agree. Now fast-forward six months: 17.2.5, 17.6.0 and 18.0.0 have been published. Someone clones the repository, runs npm install without having touched package.json and gets:

Range Installs six months later Did your code change?
17.2.1 17.2.1 No
~17.2.1 17.2.5 No
^17.2.1 17.6.0 No
* 18.0.0 No

Three people with the same repository and three different trees. That is exactly the problem the lockfile solves.

Compare the recommended range according to the project's role:

Situation Recommended range Reason
Application with a lock (Escena Viva) ^ The lock already pins the real version; the range only states the update policy
Published library Broad ^ Narrow ranges cause duplicates in the projects that use you
Dependency with a history of breaking ~ or exact Less room for surprises
Any project Never * You give up every guarantee in exchange for nothing

  1. package-lock.json: what it contains exactly

It is generated on its own as soon as there is any dependency. Here is a real fragment, trimmed:

{
  "name": "escena-viva",
  "version": "0.1.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "escena-viva",
      "version": "0.1.0",
      "license": "MIT",
      "dependencies": {
        "dotenv": "^17.2.1"
      },
      "devDependencies": {
        "prettier": "^3.6.2"
      },
      "engines": {
        "node": ">=24.5.0 <25"
      }
    },
    "node_modules/dotenv": {
      "version": "17.2.1",
      "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz",
      "integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78hjB1PYYow==",
      "engines": { "node": ">=12" },
      "funding": { "url": "https://dotenvx.com" }
    },
    "node_modules/prettier": {
      "version": "3.6.2",
      "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
      "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfQqFsuHLoDrIhcVYWyO0nAvqEhIkKMYQIsy0mRnNlqUXWkuVjK4X8oEeWmnCLYUEQA==",
      "dev": true,
      "bin": { "prettier": "bin/prettier.cjs" },
      "engines": { "node": ">=14" }
    }
  }
}

Field by field:

Field What it is
lockfileVersion: 3 The file format. Version 3 is npm 7 onwards
packages[""] The project itself: a copy of its declared ranges
version The exact resolved version. No ranges here
resolved The exact URL of the downloaded tarball
integrity SHA-512 hash of the content, in SRI format
dev: true Marks it as needed only in development (enables --omit=dev)
bin Executables to be linked into node_modules/.bin
engines The package's Node requirements, copied so they can be validated

The integrity field is what makes this security and not just convenience. On every install npm downloads the tarball, computes its SHA-512 and compares it with the recorded one. If they do not match —because someone tampered with the registry, because a mirror served something else, because a cache got corrupted— the install fails. That is cryptographic integrity across your whole dependency tree, for free.

Hence the rule that is not up for negotiation:

package-lock.json DOES go into version control. Always.

The reasons: reproducibility (your laptop, your teammate's, CI and the server all install the same thing, today and a year from now); security, because the hashes travel with the repository; debugging, since git log package-lock.json tells you which version changed and when, which is usually the answer to "this worked last week"; and auditing, because npm audit analyzes the lock's exact tree and not an approximation.

The only real exception is a published library: its lock does not affect whoever installs it, because every project resolves its own tree. Even so, many libraries commit it so their own tests are reproducible. Escena Viva is an application: it commits it without discussion.

And the flip side: the lock is never edited by hand. It is a generated file. You change it by running npm commands.

  1. npm ci versus npm install

With the lock on the table, the module's key comparison:

npm install npm ci
Reads package.json Yes Yes
Reads package-lock.json Yes, if it exists Mandatory: no lock, no run
Modifies the lock Yes, when needed Never
If the lock does not match the manifest Updates it silently Fails with an error
An existing node_modules Updates it incrementally Deletes it entirely and reinstalls
Speed Slower (it resolves versions) Faster (it just installs what is written)
Accepts npm ci <package> Yes, npm install <package> Does not exist
Intended use Development, adding dependencies CI, Docker, production

The decisive row is the fourth. If someone edits package.json by hand —bumps a range, adds a dependency— and does not run npm install, the lock is left out of sync. In that state, npm install fixes it silently; npm ci digs in its heels:

npm error `npm ci` can only install packages when your package.json and
npm error package-lock.json are in sync. Please update your lock file with
npm error `npm install` before continuing.

That error is good news: CI has detected that the repository is inconsistent before anything gets deployed. Hence the module's operational rule, which we will reuse in Module 11:

  • On your machine: npm install, npm install <package>, npm update. They modify the lock, and that change is committed to git as part of the work.
  • In CI, Docker and production: npm ci (with --omit=dev in production). Never npm install.

  1. Lock conflicts in git

It is inevitable: two branches add different dependencies, they get merged, and git presents a conflict in a three-thousand-line generated file.

What you do not do: open the file and pick by hand between <<<<<<< markers. The lock is a coherent graph; resolving it piecemeal produces an inconsistent tree that "almost" works.

What you do: discard the file and regenerate it from the manifests, which are human-readable.

# 1. Resolve the package.json conflict first: it is small and readable.
git checkout --ours package-lock.json   # or --theirs: it does not matter, it will be regenerated
npm install                             # regenerates a lock consistent with the merged manifest
npm ci && npm test                      # verifies that the result installs and works
git add package.json package-lock.json
git commit

An equally valid alternative: delete the lock and run npm install. It is more aggressive —it can raise versions inside the ranges, since everything is resolved again— so the first option is preferable when you want to touch as little as possible.

A hygiene tip: a new dependency deserves its own commit, with package.json and package-lock.json together and nothing else. That way the conflict, if it comes, is tiny, and code review can really look at what got in (05-06).

  1. npm dedupe and --save-exact

npm dedupe reorganizes the tree to share as many copies as possible. After several successive installs it is easy to end up with duplicates that are no longer needed:

node_modules/
├── package-a/
│   └── node_modules/utility/   <- 1.4.0
└── package-b/
    └── node_modules/utility/   <- 1.5.0

If both ranges accept 1.5.0, npm dedupe lifts a single copy to the root and deletes the nested ones. Less disk, less audit surface, and goodbye to the instanceof problem between different copies of the same class.

npm ls utility      # how many copies there are
npm dedupe          # reorganizes and updates the lock

It modifies node_modules and the lock, so you run it in development and commit the result. Never in production.

--save-exact (alias -E) saves the version without the ^:

npm install dotenv --save-exact     # "dotenv": "17.2.1"

Or as a project policy, in the .npmrc from 05-01:

save-exact=true

Is it worth it? The honest answer is almost never in an application with a lock, because the lock already pins the real version and the range only expresses which update policy you accept. With ^ plus a lock, npm ci is exactly as deterministic.

Pinning exact versions does pay off in three specific cases:

Case Reason
Dependency with a history of breaking in minor versions Less room for surprises
Tools that affect generated output (compilers, formatters) So the output does not change without warning
A regulated environment that requires justifying every version change Every bump is explicit and reviewable

The cost is real: with exact versions, npm update stops being useful and upgrading means touching the manifest package by package. On a large project that gets delegated to Dependabot or Renovate (05-06).

  1. The minor update that breaks things

The scenario that justifies everything above. On a Tuesday, Escena Viva's CI fails. Nobody has touched the code since Friday.

The cause: [email protected] was published on Monday. Your manifest says ^17.2.1, CI was not using a lock and npm install brought in the new version. A behavior change the author considered minor —how it interprets quotes in .env values, for example— breaks one of your tests.

What to take from that story:

^ is not a guarantee, it is an expectation. It trusts a third party to classify their own change correctly. Most of them do; getting it wrong is easy, because nobody knows every use of their own library. A change the author sees as a fix may be the very behavior you depended on.

The lock turns that expectation into a fact. With package-lock.json in the repository and npm ci in CI, Tuesday would have installed 17.2.1, just like Friday. The new version would come in the day someone runs npm update deliberately, sees the change in the lock's diff and passes the tests before merging.

The difference is not that the failure disappears: it is who controls when it happens. Without a lock, it ambushes you on a Tuesday morning in production. With a lock, it happens on a branch, with an owner and with tests in front of it.

That is why the correct order for upgrading is always the same:

npm outdated                 # what is available and of what kind
npm update                   # raises within the ranges: safe
npm test                     # verify (Module 9)
git add package-lock.json && git commit -m "Update minor dependencies"

To jump a major version, the process is different and not automatable: read the release notes, change the range by hand, adapt the code and test. A separate branch, always.

  1. From 0.1.0 to 1.0.0: versioning Escena Viva

Escena Viva was born at 0.1.0 because its API was moving. With Module 4 finished, it has a public, stable HTTP API —GET /api/events, POST /orders—, a settled domain and a client (the front end in public/) that depends on it. It is time for the jump.

npm version 1.0.0

npm version does three things, not one: it changes version in package.json and in package-lock.json, it creates a git commit with that change (with 1.0.0 as the default message) and it creates an annotated git tag, v1.0.0, which is the only thing it prints to the screen.

It also accepts bumps by name, which is how it is used day to day:

npm version patch      # 1.0.0 -> 1.0.1
npm version minor      # 1.0.1 -> 1.1.0
npm version major      # 1.1.0 -> 2.0.0
npm version 1.1.0-beta.1 --preid=beta

If you do not want the commit or the tag —because your publishing flow generates them another way— there is --no-git-tag-version.

What the jump to 1.0.0 really means in Escena Viva:

Before (0.x) After (1.x)
Any minor version could break Only a jump to 2.0.0 can break
^0.1.0 ranges accepted patches only ^1.0.0 accepts the whole 1.x series
Changing the API was routine Changing the API requires a migration plan

Concretely, from now on: removing a field from the GET /api/events response is major; adding a new field is minor; changing the meaning of an error.appCode in the Module 4 error table is major; fixing a badly computed capacity calculation is patch.

That commitment is the same one you have been demanding from dotenv throughout this lesson. Here you switch sides of the table, and that is why the module continues towards publishing.

Common Mistakes and Tips

  • Adding package-lock.json to .gitignore. It is the most expensive mistake in the module: it destroys reproducibility. What gets ignored is node_modules/, never the lock.
  • Resolving lock conflicts by hand. It produces incoherent trees that fail in strange ways. Regenerate it with npm install.
  • Using npm install in CI. It can modify the lock during the build and deploy something different from what you tested. npm ci, always.
  • Expecting npm update to bump a major version. It never does, by design. Nor minors in 0.x packages, because of the ^0.x.y rule.
  • Publishing 1.0.0 without meaning to promise stability. If your API is still moving, stay on 0.x: it is honest information for whoever uses you.
  • Putting * or latest as a range. Any major version gets in without warning. There is no case where it pays off.
  • Tip: review the lock's diff. In a code review, a lock that changes while package.json does not deserves a question.
  • Tip: npm view <package> versions --json lists every published version. Useful for seeing the release cadence before adopting a dependency.

Exercises

Exercise 1. Resolve ranges by hand. A package has these versions published: 0.9.0, 1.0.0, 1.2.0, 1.2.5, 1.9.0, 2.0.0-beta.1, 2.0.0, 2.1.0. Say what each of these ranges would install: ^1.2.0, ~1.2.0, 1.2.x, >=1.0.0 <2, ^2.0.0, *, 1.2.0 - 1.9.0. Then repeat the exercise assuming the published versions are 0.1.0, 0.2.0, 0.2.5, 0.3.0 and the range is ^0.2.0.

Exercise 2. See the lock in action. In Escena Viva, run npm ls dotenv and note the version. Open package-lock.json and find its version, resolved and integrity. Now delete node_modules, run npm ci and check that the version is identical. Then edit package.json changing the range to ^17.0.0 without running npm install and launch npm ci. Explain what happens and why it is the desirable behavior.

Exercise 3. Version Escena Viva. Run npm version 1.0.0 with a clean git tree. Check with git log -1 and git tag exactly what it created. Then decide which kind of bump (patch, minor or major) each change calls for: (a) adding the accessibleVenue field to the GET /api/events response; (b) fixing the available-seats calculation, which was subtracting wrongly; (c) renaming priceCents to amountCents in the API response; (d) changing LOW_CAPACITY_THRESHOLD from 20 to 15; (e) requiring Node 26 in engines.

Solutions

Solution 1. First part:

Range Installs Reason
^1.2.0 1.9.0 >=1.2.0 <2.0.0; the highest of the 1 series
~1.2.0 1.2.5 >=1.2.0 <1.3.0; patches only
1.2.x 1.2.5 Equivalent to ~1.2.0
>=1.0.0 <2 1.9.0 Explicitly excludes the 2 series
^2.0.0 2.1.0 Not 2.0.0-beta.1: pre-releases do not enter normal ranges
* 2.1.0 The highest stable one
1.2.0 - 1.9.0 1.9.0 The hyphen includes both ends

Second part: ^0.2.0 installs 0.2.5, not 0.3.0. That is the ^0.x.y rule: with the major at zero, the caret protects the minor number too, because 0.3.0 is considered potentially incompatible. This is the answer most people get wrong.

Solution 2. After npm ci, npm ls dotenv shows exactly the same version: the lock rules, not the range.

When you change the range to ^17.0.0 without installing, npm ci fails:

npm error `npm ci` can only install packages when your package.json and
npm error package-lock.json are in sync.
npm error Invalid: lock file's [email protected] does not satisfy dotenv@^17.0.0

It is desirable because being out of sync is a real repository error, not a detail. Someone touched the manifest without regenerating the lock, so nobody knows which tree was meant to be deployed. npm install would paper over it silently and deploy a resolution nobody has reviewed; npm ci stops the deployment and forces it to be fixed on a branch. The fix is running npm install and committing the resulting lock.

Solution 3. npm version 1.0.0 produces:

$ git log -1 --oneline
a1b2c3d 1.0.0

$ git tag
v1.0.0

$ git show --stat HEAD
 package.json      | 2 +-
 package-lock.json | 4 ++--

Classification of the changes:

Change Bump Reason
(a) Adding accessibleVenue Minor → 1.1.0 It adds information; a client that ignores it keeps working
(b) Fixing available seats Patch → 1.0.1 It fixes a bug without changing the shape of the response
(c) Renaming to amountCents Major → 2.0.0 Every client that read priceCents breaks
(d) LOW_CAPACITY_THRESHOLD from 20 to 15 Minor → 1.1.0 It changes observable behavior without breaking contracts; if that constant were documented as part of the public API, it would be major
(e) Requiring Node 26 Major → 2.0.0 It shuts out environments that used to work

Case (d) is the interesting one: the answer depends on whether that constant is part of the public contract. That ambiguity is exactly the hard part of semver, and the reason a clear CHANGELOG is worth as much as the numbers.

Conclusion

You can now read the language the whole ecosystem uses to communicate. SemVer turns three numbers into a promise: PATCH fixes, MINOR adds without breaking, MAJOR breaks. Pre-release versions are always lower than their stable counterpart and do not enter normal ranges. And from the range table you take away two shapes: ~ lets the last number rise, ^ the last non-zero-from-the-left one —hence ^0.2.3 accepting patches only, the rule almost nobody knows and the one that explains why npm update sometimes seems to do nothing.

But the underlying lesson is that ^ is an expectation, not a guarantee: it depends on a third party classifying their own change correctly. What turns that expectation into a fact is package-lock.json, with its exact tree, its resolved versions, its URLs and above all its SHA-512 integrity, which makes the install fail if the downloaded content is not byte for byte what was recorded. That is why it always goes into the repository, why it is never edited by hand, and why its git conflicts are resolved by regenerating it rather than picking between markers.

From that comes the operational rule that will stay with you to the end of the course: npm install on your machine —it modifies the lock, and that change gets committed and reviewed— and npm ci in CI, in Docker and in production, which requires the lock, honors it to the letter, deletes node_modules and fails if the manifest and the lock do not match. That failure is good news: it stops an inconsistent deployment before it reaches anyone.

Escena Viva is now 1.0.0, with its v1.0.0 git tag created by npm version, and that number is a commitment: from today on, removing a field from the API or changing the meaning of an error.appCode forces a bump to 2.0.0.

In the next lesson, npm Scripts and Project Automation, we fill the manifest's last gap. The scripts field is about to become the project's single interface —npm start, npm run dev, npm run report, npm test— so nobody has to read the README to guess a command. And along the way you will discover why a tool you never installed globally works anyway: the PATH extended with node_modules/.bin.

Node.js Course: From Beginner to Advanced

Module 1: Introduction to Node.js

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved