/ Tags: DEVELOPER TIPS / Categories: TIPS

Writing Changelogs That Developers Actually Read

Most changelogs are a dump of commit subjects. “Merge pull request #412 from feature/fix-thing”, “wip”, “address review comments”. Nobody reads them, so nobody maintains them, so they get worse — and the one time someone genuinely needs to know what changed between 2.3 and 2.4, they read the diff instead. A changelog is only worth keeping if it answers a question the diff can’t.

The Question a Changelog Answers


Not “what changed” — git already knows that, in more detail and with more accuracy than you’ll ever write by hand.

The question is: “I’m upgrading. What do I need to do?”

Everything follows from that. A reader arrives with a version they’re on, a version they want, and a decision to make. Your job is to get them to the decision quickly.

Which means the audience is not you. It’s someone who doesn’t know your codebase, doesn’t know your internal ticket numbers, and doesn’t care which branch it came from.

The Format


Keep a Changelog is the de facto standard and it’s a good one, mostly because the category names are the right ones.

Example:

# Changelog

All notable changes to this project are documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/).
This project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added
- `Report#to_parquet` for columnar export.

## [2.4.0] - 2026-06-02

### Added
- `Client#stream` yields records as they arrive instead of buffering (#428).
  Useful for exports over ~50k rows where memory was the constraint.

### Changed
- Default request timeout raised from 5s to 15s (#431).
  Set `config.timeout = 5` to restore the previous behaviour.

### Deprecated
- `Client#fetch_all` is deprecated in favour of `Client#stream`.
  It will be removed in 3.0. Calls now emit a deprecation warning.

### Fixed
- Retries no longer double-count against the rate limit budget (#419).
- `parse_date` handles the `DD-MM-YYYY` format used by the EU endpoint (#425).

### Security
- Bumped `rack` to 3.0.11 for CVE-2025-XXXXX (DoS in multipart parsing).

[Unreleased]: https://github.com/org/repo/compare/v2.4.0...HEAD
[2.4.0]: https://github.com/org/repo/compare/v2.3.2...v2.4.0

Six categories, in this order: Added, Changed, Deprecated, Removed, Fixed, Security. Omit the empty ones. Newest release at the top. Comparison links at the bottom so a reader can jump straight to the diff when they want it.

Write Entries for the Reader, Not the Author


The difference between a useless entry and a good one is almost always the same three additions.

Instead of Write
“Fixed bug in parser” parse_date handles DD-MM-YYYY; previously raised ArgumentError on EU-formatted dates (#425)”
“Updated dependencies” “Minimum Ruby is now 3.1 (was 3.0). Drop reason: Data class usage”
“Refactored the client” Client no longer holds a persistent connection. No API change; reduces idle socket count on long-running processes”
“Improved performance” “Bulk import is ~4× faster on 100k rows (12s → 3s) via insert_all

Three things every entry should carry when they apply:

  1. The user-visible effect — what behaves differently
  2. The migration action — what to do about it, if anything
  3. A reference — issue or PR number, so the detail is one click away

Numbers beat adjectives. “Faster” is a claim; “12s → 3s on 100k rows” is information.

Breaking Changes Deserve More Space


Everything else can be one line. A breaking change needs before-and-after.

Example:

### Changed

- **BREAKING** `Report#generate` now requires a `locale:` keyword argument (#440).

  ```ruby
  # Before
  report.generate(format: :pdf)

  # After
  report.generate(format: :pdf, locale: :en)

There’s no default because the previous implicit :en produced silently wrong currency formatting for non-US accounts. If you only serve one locale, set it once via Report.default_locale = :en.


The paragraph explaining *why* is what stops people from filing an issue asking why. It costs three sentences and saves a support thread.

## What Not to Include
---

- **Internal refactors with no external effect.** If nobody outside can tell, it's not notable. The commit history has it.
- **Merge commits and CI changes.** Not the reader's concern.
- **Ticket IDs from your private tracker.** `PROJ-882` is meaningless to anyone who can't open it. Link the public issue instead.
- **Every dependency bump.** Group them: "Updated development dependencies." Call out only the ones with a user-visible effect or a security fix.
- **Marketing language.** "We're thrilled to announce" belongs in a release blog post, not here.

## Keeping It Current
---

The reason changelogs rot is that they're written at release time, from memory, for thirty commits. Two approaches that work.

##### **The Unreleased section**

Every PR that changes behaviour adds a line under `## [Unreleased]`. At release, rename the heading with a version and date and start a fresh Unreleased. The entry gets written by the person who made the change, at the moment they best understand it.

Enforce it in CI:

```yaml
- name: Require changelog entry
  run: |
    if git diff --name-only origin/main...HEAD | grep -qE '^(app|lib)/'; then
      git diff origin/main...HEAD -- CHANGELOG.md | grep -q '^+' \
        || { echo "Code changed but CHANGELOG.md did not."; exit 1; }
    fi

Add a skip-changelog label check so internal refactors can opt out honestly rather than by adding a filler line.

Changeset files

The alternative that avoids merge conflicts: each PR adds a small file to changelog.d/, and a release script concatenates and deletes them.

changelog.d/
  428-stream-records.md
  431-timeout-default.md
  419-retry-rate-limit.md

Two PRs never conflict, because they touch different files. Tools like towncrier automate the assembly. Worth it once you have more than a few contributors — every team using a single CHANGELOG.md at scale eventually gets tired of rebasing it.

Pro-Tip: Generate the first draft from your commits, then rewrite every line by hand. git log --pretty='- %s (%h)' v2.3.2..HEAD gives you the complete list so nothing gets forgotten, which is the part humans are bad at — and then you do the part tools are bad at, which is saying what each change means for someone upgrading. Fully-automated changelogs from Conventional Commits look tidy and read like a build log, because a commit subject is written for a reviewer who has the diff open, while a changelog entry is written for a stranger who doesn’t. Use the generator as a checklist, not as the output.

Version the Migration Guide Separately


For a major release, the changelog lists what broke. An UPGRADING.md explains how to get from N to N+1 in order.

# Upgrading from 2.x to 3.0

## 1. Update Ruby to 3.1+
...

## 2. Add the required `locale:` argument
Find call sites: `grep -rn 'generate(' --include='*.rb'`
...

## 3. Replace `fetch_all` with `stream`
...

Sequenced steps with commands to run. The changelog says what changed; the upgrade guide is a procedure. Cross-link them and don’t duplicate.

Conclusion


Write the changelog for someone deciding whether and how to upgrade, not for someone auditing your work. Use the six Keep a Changelog categories, put the newest release at the top, and make every entry state the user-visible effect plus the action to take, with a link for the detail. Give breaking changes a before/after snippet and a sentence on why. Add entries per-PR under Unreleased — or as changeset files if merge conflicts are hurting — and enforce it in CI so it doesn’t decay. Generate the draft from git so nothing’s missed, then rewrite it in human language, because that translation is the entire value of the document.

FAQs


Q1: Should applications keep a changelog, or only libraries?
Libraries always — consumers depend on it. For a deployed application with no external consumers, release notes for the team serve a similar purpose but the audience is different and the bar is lower.

Q2: How do I handle changelogs across a monorepo?
One per publishable package, not one for the repo. A consumer of package A doesn’t care about package B’s fixes. Changeset tooling handles this well.

Q3: Are Conventional Commits enough on their own?
They give you accurate categorisation and a complete list, which is most of the tedious part. They don’t give you the “what this means for you” sentence, which is most of the value. Use both.

Q4: Where should the changelog live?
CHANGELOG.md at the repo root — that’s where tooling and humans both look. Mirror it into GitHub Releases so it appears in notifications and in the dependency-update PRs your users’ bots open.

Q5: What about unreleased breaking changes on a main branch?
Keep them under Unreleased with the BREAKING marker from the moment they merge. Someone tracking main needs to know, and you won’t remember to add it at release time.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More