ManagementJunior

Changelog

A changelog is a curated, human-readable record of the notable changes in each version of software, written for the person deciding whether to upgrade. That reader is why a dump of commit messages, however complete, is not one. The page opens with a finished changelog, then covers the documents people confuse with it, what a version number promises, how to write an entry, deprecation, how far automation honestly goes, four releases where the changelog line was the problem, and where the practice degrades.

Written for
Whoever decides to upgrade
Format
Keep a Changelog 1.1.0
Version scheme
SemVer 2.0.0, since 2013

A changelog on one screen — finished

Most people who look up changelogs want to see a good one. Here is one, for a small library inside the online shop used across the Decomposition and Release Process pages. orders-client is the package through which four teams read orders: the web shop, the support console, the order confirmation emails and the nightly analytics export. Each team decides for itself when to upgrade, and that is exactly the reader a changelog is for. Only part of the file fits on a screen: the header, the Unreleased section and four versions. Later sections cite the lines by number.

  1. 1.Header — what the file is and what it promises

    1. All notable changes to orders-client are recorded here, newest first. The format follows Keep a Changelog 1.1.0
    2. Versions follow Semantic Versioning 2.0.0. The public is what docs/api.md lists: exported functions, the fields of returned objects and error codes. Anything else may change in any release
    3. Deprecation policy: anything deprecated keeps working until the next major version, and for at least six months after the release that deprecated it
  2. 2.[Unreleased]

    1. Added — order.reorderSource: history, saved-basket or null, saying how a repeat order was started. Orders placed before 20 October always have null
    2. Deprecated — getOrders({ customerId }). Use getCustomerOrders(customerId) from 3.1. Removed in 3.0.0, and no sooner than six months after this release. Calling it logs one warning per process, and the endpoint behind it answers with Deprecation and Sunset headers
  3. 3.[2.7.0] — 2026-09-15

    1. Added — getCustomerOrders(customerId), which pages through a history of any length. getOrders stopped at 100 orders without saying so
    2. Added — address.postcode, holding the same value as address.zip
    3. Deprecated — address.zip. Read address.postcode instead. zip stays until 3.0.0, and at least until 15 March 2027; the first read in a process logs a warning
    4. Security — the customer’s email address is no longer written to the debug log (DEBUG=orders-client). Upgrade now if debug logging was ever switched on in production
  4. 4.[2.6.2] — 2026-08-27

    1. Fixed — order.placedAt is in again, as documented. 2.6.1 returned the server’s local time
  5. 5.[2.6.1] — 2026-08-26 [YANKED]

    1. Fixed — order.total now includes the voucher discount. Do not use this version: it also switched placedAt to local time, which put 212 late-evening orders on the wrong day in the nightly export. Pulled the next morning; use 2.6.2
  6. 6.[2.0.0] — 2026-03-02

    1. Removed — getOrderLines(orderId). The lines are now in order.lines. See “Moving to 2.0”, step 1
    2. Changed — money is a whole number of the smallest currency unit: total: 4999, not "49.99". Every place that formats or adds up a price must change. See “Moving to 2.0”, step 2
  7. 7.Links

    1. Every version heading links to its diff — [2.7.0]: …/compare/v2.6.2...v2.7.0 — and [Unreleased] compares the last tag with main

A git log is not a changelog — and neither are release notes

The word has two meanings, and that is half of the confusion. The GNU Coding Standards ask for a ChangeLog file that records every change to every source file, so that “people investigating bugs in the future” can trace them. Today they accept the version-control log in its place. The same standards ask for a second file, NEWS: “a list of user-visible changes worth mentioning”, newest first. What Keep a Changelog calls a changelog is the second file under the first file’s name. Olivier Lacan first published the convention in 2014, and it defines the document as “a curated, chronologically ordered list of notable changes for each version of a project”.

Changelog, version 2.7.0

  • Added — getCustomerOrders(customerId); getOrders stopped at 100 orders
  • Deprecated — address.zip; read address.postcode
  • Security — email addresses no longer reach the debug log

The commits behind it

  • feat: paginate history
  • feat(address): add postcode
  • fix: redact pii in debug
  • refactor: split http module
  • Merge branch 'feat/postcode'

Read the right-hand column as one of the four consuming teams. Nothing in it says that zip will disappear, which is the one thing that creates work. The third commit is a security fix filed as an ordinary fix. And the last two say nothing at all to anyone outside the team.

Four documents, four readers

Most bad changelogs are one file trying to be several. The four documents below differ in who reads them, when that person opens them and what they need to find.

DocumentReaderOpened whenContainsWritten
Commit logThe team, a year laterTracing a bug to the change that caused itEvery step, in order, in the team’s wordsWith each change, by its author
ChangelogA developer who depends on youBefore an upgrade, or after one broke somethingEvery notable change, one line each, grouped by typeWith each change, edited at release
Release notesUsers who did not follow the workWhen the release is announcedThe few changes worth announcing, explainedAt release, by product or marketing
Migration guideSomeone in the middle of a major upgradeWhen the build has just failedFor each breaking change: before, after, and how to checkWith the breaking change, before the major ships

The reorder release from the Release Planning page produces three of them. Line 2.1 above is the changelog line for the four consuming teams. Customers get a release note instead: “Repeat a past order from your order history in two taps. Anything out of stock is named, with a substitute.” That is line 1.4 of the release plan. And when 3.0.0 removes zip, the migration guide gets a step with the old read, the new read, and a search to find every remaining use.

The version number is a promise

Semantic Versioning turns three numbers into a compatibility contract. Tom Preston-Werner wrote it; version 1.0.0 of the specification was tagged in September 2011 and 2.0.0, still current, in June 2013. MAJOR goes up for incompatible changes to the public API, MINOR for backward-compatible functionality, PATCH for backward-compatible bug fixes. The point is not tidiness. A reader can decide whether an upgrade is safe without reading the diff, and a package manager can decide it on their behalf.

In semantic versioning each segment is a promise. A major bump means existing users must change something. A minor bump adds capability while staying compatible. A patch fixes behaviour without changing the contract — so a reader can decide whether an upgrade is safe without reading the diff.

The rule that carries the weight: if existing users must change anything to keep working, that is a MAJOR bump, however small the change looks from the inside. Renaming a field, tightening a validation rule, making an optional parameter required and changing a default are all breaking changes. Shipping one of them as a patch is how a dependency earns its reputation.

ChangeBumpWhy
Add a function or an optional field (3.1, 3.2)MINORNothing that worked stops working
Mark something deprecated (3.3)MINORThe specification requires it, in item 7
Make the code do what the docs say (4.1)PATCHUnless many users rely on the bug — then, in the FAQ’s words, “use your best judgment”
Remove or rename anything public (6.1)MAJOREvery use of it stops compiling or starts failing
Change a type or a format (6.2)MAJORThe code still runs and computes the wrong thing
Make a parameter required, or validate input more strictlyMAJORCalls that were accepted are now rejected
Change a default valueMAJORCode that never set it now behaves differently — and tools file it as a fix
Drop an old runtime, such as a Node.js versionMAJORBy convention — the Conventional Commits spec uses exactly this as its example
Change how the package is loaded — an ES module, an exports mapMAJORImports that worked stop resolving; see is-promise below
Update a dependency without changing the public APIPATCH or MINORThe FAQ: compatible, and the reason for the update decides which
Three questions, asked in order, decide which number moves. A change outside the declared public API is free to change, a patch at most. Inside it, a change that forces any user to edit their code is a MAJOR, as lines 6.1 and 6.2 of the changelog are. One that adds or deprecates something is a MINOR, as 3.1 and 3.3 are. Anything else is a PATCH, as 4.1 is. The second question decides most bumps, and only knowing how people use the software can answer it.

Zero, pre-release and build metadata

Anything starting with 0 is explicitly outside the promise: during 0.y.z, item 4 says, “Anything MAY change at any time.” That is honest while a design is still moving and dishonest once people depend on it. The specification’s own FAQ is blunt: “If your software is being used in production, it should probably already be 1.0.0.” A project that has sat on 0.x for four years with production consumers is using the zero to avoid the commitment.

A pre-release is marked with a hyphen — 3.0.0-rc.1 — and sorts before the release it precedes, so a package manager asking for 3.0.0 or higher will not pick it up by accident. Build metadata is marked with a plus — 3.0.0+build.482 — and is ignored entirely when versions are compared. That makes it the right place for a commit hash and the wrong place for anything a user needs to know. And a released version is never changed: if 2.6.1 turns out to be broken, the fix is 2.6.2, and 2.6.1 stays in the file, marked, as group 5 does.

The contract and everything else people depend on

SemVer can only promise what the public API says. Hyrum Wright, then at Google, put the problem in one sentence that Titus Winters later named Hyrum’s Law: “With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody.” The order of results, the time a call takes, the exact wording of an error — somebody’s code reads it.

Package managers make that sharper. In npm, a dependency written as ^2.6.0 accepts any 2.x.y at or above 2.6.0, so a new minor reaches every consumer on its next fresh install, and nobody reads the changelog first. A holds the old version until someone updates it, which delays the surprise but does not cancel it. That is why a MINOR that breaks something travels further and faster than a MAJOR ever could.

Other numbering schemes, and what each one promises

SchemeExampleThe number tells youCompatibility is promised by
Semantic VersioningLibraries and packages: 2.7.0What kind of change it isThe number itself
Calendar versioningUbuntu 24.04 (year and month); pip 25.2 (year and release)How old it isA separate policy — pip deprecates for at least six months
Dated API versionsStripe: 2026-09-30.endiveWhich behaviour you asked forPinning: each account stays on its version until it upgrades
One rising numberChrome and Firefox, every four weeksOnly the order of releasesDeprecation processes for web features, not the number
Major version in the import pathGo modules: example.com/lib/v2Which incompatible line this isThe path: “If an old package and a new package have the same import path, the new package must be backwards compatible”

Stripe’s scheme is worth a second look, because it answers the question SemVer leaves to the consumer: when do I take the breaking change? Every account is pinned to the API version that was current when it started, and a request can ask for another with the Stripe-Version header. By 2017 Stripe had shipped “almost a hundred backwards-incompatible upgrades” that way without breaking anyone who did not opt in. Since 2024-09-30.acacia, monthly versions carry no breaking changes, and a major release with breaking changes comes twice a year.

Writing an entry someone can act on

Keep a Changelog groups every release under six headings. Using the same six everywhere means a reader can scan for the one they care about — usually Removed and Changed, because those create work.

HeadingForThe reader asksOn this page
AddedNew featuresIs there something I can now do?2.1, 3.1, 3.2
ChangedExisting behaviour that is now differentWill my current code behave differently?6.2
DeprecatedStill works, will be removedWhat must I move off, and by when?2.2, 3.3
RemovedGone in this versionWhat breaks the moment I upgrade?6.1
FixedBugs resolvedCan I drop my workaround?4.1, 5.1
SecurityVulnerabilities closedAm I affected, and how urgently must I upgrade?3.4

The rest of the convention is mechanical and worth following exactly, because it is what lets a reader find things. Its guiding principles: changelogs are for humans, not machines; an entry for every single version; the same types of change grouped; versions and sections linkable; the latest version first; the release date of each version shown; and a statement of whether the project follows Semantic Versioning. Dates are ISO 8601 — 2026-09-15, not 15/09/26, which reads as two different days depending on the reader’s country. An Unreleased section sits on top, and a pulled version stays in the file with a loud [YANKED] tag.

Eight entries as teams write them, and the rewrite

As writtenWhat the reader cannot tellRewritten
“Bug fixes and improvements”Anything at allOne line per change, as in 4.1
“Updated the token checks”Whether it affects themThe effect and the condition — see the example below
“Refactored the HTTP layer”There is nothing to observeDelete the line
“Fixed #4812”What was brokenName the symptom: “getOrders stopped at 100 orders without saying so”, as in 3.1
“Removed legacy endpoint”Which one, and what insteadName it, its replacement and the guide step, as in 6.1
“Performance improvements”Where, and by how much“getCustomerOrders for 500 orders: 280 ms, was 1.9 s”
“Deprecated zip”What to use instead, and until whenReplacement, removal version, earliest date, as in 3.3
“Security fixes”Whether they are affected, and how urgent it isWhat leaked or was possible, under which setting, and who must upgrade, as in 3.4

Two habits do most of the remaining work. Keep the Unreleased section filled as changes land, so each entry is written while the change is fresh, by the person who made it. And write each line as the effect on the reader, not as the edit in the code. A changelog assembled the day before release is always the thin one, because whoever assembles it is reconstructing a month from commit subjects.

Deprecation — the part that buys goodwill

Removed is the heading that creates work; Deprecated is the heading that gives people time to do it. A deprecation is a promise in three parts, and a notice missing any of them is a complaint about your own code.

  • What is going away, named precisely enough that a reader can search their own code for it — address.zip, not “the old address fields”.
  • What to use instead, available now rather than promised for later — line 3.2 shipped postcode in the same release that deprecated zip.
  • When it will be removed, as a version and an earliest date rather than a season — “3.0.0, and not before 15 March 2027”.

SemVer builds the sequence in. Item 7 says a minor release MUST be made when anything public is marked deprecated, and the FAQ asks for “at least one minor release that contains the deprecation” before the major that removes it. Keep a Changelog states the goal from the reader’s side: “It should be possible to upgrade to a version that lists deprecations, remove what’s deprecated, then upgrade to the version where the deprecations become removals.” Line 1.3 adds a floor in time, because a consumer that upgrades twice a year might otherwise meet the deprecation and the removal in one step.

ProjectSignal where the code runsMinimum before removal
Python (PEP 387)A DeprecationWarning at runtimeTwo minor versions; five years preferred. distutils: deprecated in 3.10, removed in 3.12
Node.jsFour kinds, numbered from DEP0001: documentation-only, application, runtime, end-of-lifeNo fixed window; a deprecation moves up the kinds before removal
KubernetesSince v1.19, a warning on every request to a deprecated API, and a metric of who still calls itBeta: 9 months or 3 releases, whichever is longer. GA: never within a major version
pipA warning when the behaviour is usedSix months; three when two maintainers agree to fast-track it
StripeNone needed: the account stays on its pinned versionUntil the customer upgrades
Any HTTP APIDeprecation (RFC 9745, 2025) and Sunset (RFC 8594, 2019) response headersWhatever the provider publishes — the headers carry the dates

Deprecating an HTTP endpoint

The endpoint behind line 2.2, GET /orders?customer=, can say so in every response. Deprecation: @1789430400 gives the moment it was deprecated, 15 September 2026, as Unix seconds after an @. Sunset: Mon, 15 Mar 2027 00:00:00 GMT gives the earliest moment it may stop answering, in the older HTTP-date format. A Link header with rel="deprecation" points to the page that explains the move. 9745 adds two rules worth knowing. Deprecation “does not change any behavior of the resource”, and the Sunset date must not be earlier than the Deprecation date.

A header marks a resource, not a field, so it cannot announce line 3.3. address.zip is announced in the changelog and the docs, and the library logs a warning the first time a process reads it. That warning is also how the team learns who still depends on it.

Line 3.3 deprecates address.zip in 2.7.0, and line 1.3 sets the earliest removal six months later, on 15 March 2027. The four consuming teams move to postcode at their own pace. Three have moved by January; the analytics export is still reading zip in March, so the team tells it by name, and it moves on 30 March. Version 3.0.0 ships on 13 April, after the last reader moved — not on the earliest date the policy allowed.

How far automation honestly goes

Conventional Commits 1.0.0, published in 2019 and grown out of the Angular project’s commit convention, standardises the first line of a commit message so a tool can read it: a type, an optional scope, an optional ! for a breaking change, then the description — “feat(address): add postcode”, “fix!: require a currency on every price”. A breaking change can also be stated in a footer beginning BREAKING CHANGE:, which is the better place when it needs a sentence of explanation. Types other than feat and fix are allowed and, the spec says, “have no implicit effect in Semantic Versioning”.

CommitImpliesLands under
fix:PATCHFixed
feat:MINORAdded
! or BREAKING CHANGE:MAJORChanged or Removed
chore:, docs:, refactor:, test:NothingUsually nowhere — no effect a user can see
Deprecated, SecurityNo type of their ownA person has to put them there — the commit in the comparison above was a plain fix

This genuinely helps. A tool can derive the next version number and assemble a draft under the right headings, which removes the two most tedious steps. What it cannot do is decide whether a change is breaking, because that depends on how people use the thing, and that knowledge lives outside the repository. A commit marked fix: that changes a default value is breaking, and the tool will call it a patch with full confidence.

ToolReadsProducesWhere a person decides
semantic-releaseCommit messagesThe version, the notes and the published package, with no human stepOnly in each commit message; its README says it “removes the immediate connection between human emotions and version numbers”
release-pleaseConventional CommitsA release pull request that keeps the changelog and the bump up to dateEditing and merging that pull request
ChangesetsA small Markdown file per change: the bump and the summaryThe changelog and the new versionsIn the pull request, by its author, reviewed with the code
towncrier“News fragments” — one file per change, written for end usersThe news file at release; used by pip, pytest and TwistedIn the pull request, like Changesets
From commit messages, the changelog line is written at release from memory, and the bump is guessed from commit types — so a fix that changed a default goes out as a patch. From fragments, each pull request carries its own line and bump, written by its author and reviewed with the code, so the reviewer sees the default change and the line that describes it together. A release pull request then collects the fragments.

The last two answer the problems the earlier sections raised. The entry is written when the change is fresh, by the person who understands it, and reviewed in the same as the code — so the reviewer who sees a default change also sees the line that calls it a patch. And because each change brings its own file, there is no single CHANGELOG.md that every branch edits and every merge conflicts on. The towncrier README puts the reason plainly: it delivers the news “convenient to those that hear it, not those that write it.”

Four releases where the changelog line was the problem

Each case below is documented by the project itself. In none of them was the code the whole story. Each time, the number, the line or the signal that should have reached the reader did not.

CaseWhat happenedWhat the reader neededLines
is-promise, 25 April 2020A minor release changed how the package loads; popular tools broke on fresh installs for three hoursA MAJOR number1.2, 6.2
Kubernetes 1.16, 18 September 2019APIs deprecated for almost two years stopped answering; many users first heard of it thenA signal where the code runs, not only a line in release notes2.2, 3.3
Kubernetes and dockershim, December 2020A correct deprecation line was read as “Kubernetes drops Docker”Who is affected — and a separate document for everyone who is not3.4
Log4j, December 2021Four releases in eighteen days, each the safe one until the nextAffected and fixed ranges for every supported line3.4

is-promise, 2020 — a minor that was a major

is-promise is a one-line library: a function that says whether a value is a promise. In the week before 25 April 2020 it was downloaded about 11.7 million times, almost all of it as a dependency of something else. That day at 15:03 UTC, its maintainer published 2.2.0, the first release since September 2015. It added an ES module entry point, with "type": "module" and an exports map in package.json, and the map was malformed. Every project depending on ^2.1.0 took the new minor on its next fresh install. Freshly installed copies of Create React App, Angular’s command-line tool and Firebase’s tools stopped working.

2.2.1 followed at 17:55 and 2.2.2, without exports, at 18:08 — about three hours in all. The maintainer’s own comment on the issue is the useful part: “I read the documentation, and then made this change thinking it was pretty safe.” The next day the same exports change was published again, correctly formed, as 3.0.0. A change to how a package is loaded is part of its public API, and the number has to say so.

Kubernetes 1.16, 2019 — two years of notice nobody saw

Kubernetes 1.16, released on 18 September 2019, stopped serving Deployments, DaemonSets, StatefulSets and ReplicaSets from the old extensions/v1beta1 and apps/v1beta1/v1beta2 API versions. The replacement, apps/v1, had been available since v1.9 in December 2017. The deprecation was in the release notes, and the project published a blog post in July warning about the removal. Manifests and Helm charts still written against the old versions failed after the upgrade all the same.

The project’s answer, in v1.19 (2020), moved the notice to where the code runs. Every request to a deprecated API now returns a warning header, which kubectl prints, naming the release that will stop serving it and the replacement. A metric, apiserver_requested_deprecated_apis, tells administrators which deprecated APIs are still being called. That is line 2.2’s warning per process and the Sunset header, at the scale of a whole ecosystem.

dockershim, 2020 — a correct line, read wrongly

The v1.20 release notes, in December 2020, deprecated dockershim: the adapter that let the kubelet use Docker Engine as its container runtime. The line was accurate. It was read as “Kubernetes is dropping Docker”, and what the project later called panic turned into “a conflagration on social media”. Most readers were not affected at all, because images built with Docker run on every runtime. The project wrote “Don’t Panic: Kubernetes and Docker” and an FAQ. In 2022, when v1.24 removed the shim, a project post said it plainly: “We didn’t do a great job communicating this … This was our fault.”

The changelog line was the right document for cluster operators. Everyone else needed a different one — release notes saying who is affected and who is not — and it arrived after the line had already been misread. When a deprecation touches a name people recognise, write that second document first.

Log4j, 2021 — the Security heading under pressure

-2021-44228 in Log4j 2, scored 10.0, allowed remote code execution through a logged string. Version 2.15.0 reached Maven Central on 9 December 2021. Further problems followed, and so did 2.16.0 on 13 December, 2.17.0 on 18 December and 2.17.1 on 27 December — each of them “the safe version” until the next. Older Java lines got their own fixes: 2.12.x for Java 7 and 2.3.x for Java 6. For three weeks, teams around the world opened one page again and again to answer two questions: am I affected, and what do I move to?

The Apache Logging security page answers them in a fixed shape for every vulnerability: the affected range, the fixed version for each Java line, and a mitigation for those who cannot upgrade. That is the shape a Security entry needs, scaled down. Line 3.4 says what leaked, under which setting, and who must upgrade. “Security fixes” would answer neither question.

Where it degrades — and what a changelog cannot do

The test of a changelog is whether anyone opens it under pressure. Something broke after an upgrade at 16:40, and a person who has never read your code opens the file. In under a minute they need to find whether the version they moved to changed the thing in front of them. Everything in the convention — newest first, six fixed headings, one line per change, effects rather than edits — exists to make that minute possible. These are the ways it stops being possible.

  • A generated commit dump published unedited, which nobody reads twice.
  • Breaking changes shipped as a minor or a patch, which spend the trust the number was supposed to carry.
  • No declared public API, so every refactoring is arguably breaking and nobody can tell.
  • Years on 0.x with production consumers, which uses the zero to dodge the commitment.
  • Deprecations with no replacement or no removal date, which nobody acts on until the removal lands.
  • A removal timed by the calendar alone, while someone is still calling the thing.
  • Security entries that do not say who is affected, which turn every reader’s afternoon into an investigation.
  • A broken version deleted instead of yanked, so the people who pinned it cannot learn why it vanished.

When to use it

Use it when

  • Other people or teams depend on your software and decide for themselves when to upgrade.
  • You publish a library, an API or anything else with versions.
  • Support needs to know which version a reported problem belongs to.
  • You deprecate things, and need one place where the dates live.

Avoid it when

  • An internal module with one consumer sitting next to you — a message is cheaper.
  • A continuously deployed web app whose users never choose a version — write release notes instead.
  • It would be generated and never edited — that is maintenance without a reader.
  • Release notes for the same readers already cover every change — do not keep two overlapping documents.

Sources and method

What this page stands on

  • 28 sources
  • 4 documented failures
  • Checked October 13, 2026

Researched with Claude. Finding and cross-checking the primary sources was done with Claude (Anthropic). Selection, writing and fact-checking: the Anatomy of IT Product editors.

Found this useful?

Share it with someone who is working on the same problem.