LogPulse / guides

How to keep a changelog for a Python package on PyPI

PyPI and pip give you more release tooling than most maintainers use: a well-known Changelog link, pre-releases pip skips by default, yanking and Requires-Python. Here's how to tie them to a changelog people can upgrade from.

Updated Last checked 5 min readOpen-source libraries and packages

The short answer

  • Add a Changelog URL under [project.urls] in pyproject.toml; PyPI lists it with the project's links.
  • Use PEP 440 versions. pip skips pre-releases like 2.0.0rc1 unless asked with --pre or a specifier that names one.
  • Yank broken releases instead of deleting them, and mark them [YANKED] in the changelog.
  • Raise requires-python in the release that drops a Python version, and mark that entry **Breaking:** in the changelog.
  • Warn in code with DeprecationWarning (or @warnings.deprecated on Python 3.13+) before you remove anything.

Changelog entry for a Python package

A major release that drops a Python version, plus a yanked patch release, in Keep a Changelog 2.0.0 format.

CHANGELOG.md
## [2.0.0] - YYYY-MM-DD

### Changed
- **Breaking:** Python 3.10 or later is required (`requires-python = ">=3.10"`).
  On Python 3.9, pip keeps installing 1.x.
- **Breaking:** `Client.fetch()` is now async. Replace `client.fetch(url)` with
  `await client.fetch(url)` inside an async function.

### Deprecated
- `Client.fetch_sync()`: use `asyncio.run(client.fetch(url))`.
  It emits a DeprecationWarning. It will be removed in 3.0.0.

### Removed
- **Breaking:** `mylib.compat`, deprecated since 1.6.0.

### Fixed
- Timeouts now apply to DNS lookups as well as connections (#211).

## [1.9.1] - YYYY-MM-DD [YANKED]
Yanked: the wheels were built without the C extension. Use 1.9.2.

[2.0.0]: https://github.com/OWNER/REPO/compare/v1.9.2...v2.0.0

mylib, its API and the versions are placeholders.

Free tool · no sign-up

Can a coding agent read your changelog?

Paste your repository, a GitHub release or a CHANGELOG.md link. The free converter shows every version, breaking change, migration step and deprecation it can extract, as JSON plus an llms.txt summary.

No AI and nothing stored. Or paste a CHANGELOG.md instead

01Link the changelog from your package metadata

Add your changelog to [project.urls]. PyPI shows these URLs as project links on the package page, and the well-known project URLs specification recognises a Changelog label (with aliases such as ‘What's New’ and ‘History’) and a separate Release Notes label, so tools know which link is which.

pyproject.toml
[project]
name = "mylib"
version = "2.0.0"
requires-python = ">=3.10"

[project.urls]
Homepage = "https://example.com"
Documentation = "https://example.com/docs"
Repository = "https://github.com/OWNER/REPO"
Changelog = "https://github.com/OWNER/REPO/blob/main/CHANGELOG.md"

02Version with PEP 440, and know what pip installs

Python versions follow PEP 440: pre-releases look like 2.0.0a1, 2.0.0b2 or 2.0.0rc1, post-releases like 2.0.0.post1 and development releases like 2.0.0.dev3. pip ignores pre-releases and development releases unless you pass --pre or a specifier that includes one (such as >=2.0.0rc1), so a release candidate won't reach people by accident.

Give each pre-release its own changelog entry, then let the final release's entry summarise everything since the last stable version.

03Yank, don't delete

If a release is broken, yank it on PyPI. Under PEP 592, installers ignore a yanked release unless it's pinned exactly with == or ===, so pinned installs keep working while new installs skip it. Deleting a release can't be undone, and PyPI won't let you upload a file with the same name again.

Keep the entry in your changelog and mark it with [YANKED] after the date, plus one line saying why and which version to use instead. Keep a Changelog 2.0.0 is blunt about it: “List it; do not hide it.”

04Dropping a Python version

Raise requires-python in the release that drops support. pip compares it with the running interpreter and picks the newest release that still supports it, so people on the old Python quietly stay on your previous version (see the Python Packaging guide). Mark the entry **Breaking:** in the changelog anyway: people who pin versions or build from source still need to know.

05Warn before you remove

Emit a DeprecationWarning from anything you plan to remove, and name the replacement in the message. Python hides DeprecationWarning by default except in code run directly as __main__ (PEP 565), but pytest shows it by default, which is where many of your users will see it.

Deprecation warnings in Python
import warnings

def fetch_sync(url):
    warnings.warn(
        "fetch_sync() is deprecated and will be removed in 3.0.0; "
        "use asyncio.run(client.fetch(url)) instead.",
        DeprecationWarning,
        stacklevel=2,
    )
    ...

# Python 3.13+: the decorator also tells type checkers (PEP 702).
# On older versions: from typing_extensions import deprecated
from warnings import deprecated

@deprecated("Use asyncio.run(client.fetch(url)); removal planned for 3.0.0.")
def fetch_sync(url): ...

Then list it under Deprecated in the changelog with the replacement and the planned removal version. More in how to write deprecation notices.

06Collect entries with news fragments

On a project with many contributors, merge conflicts in CHANGELOG.md get old fast. towncrier has each pull request add a small ‘news fragment’ file; at release time it assembles them into the changelog and removes the fragments. pip uses it: fragments go in a news/ directory and are compiled into NEWS.rst.

towncrier
# pyproject.toml
[tool.towncrier]
directory = "changes"
filename = "CHANGELOG.md"

# In a pull request: add one fragment per change
towncrier create 123.feature.md --content "parse() accepts pathlib.Path objects."

# At release time: build the changelog section for the new version
towncrier build --version 2.1.0

Before and after

Too vague

2.0: Python 3 cleanup.

Specific and actionable

2.0.0 requires Python 3.10+ (pip keeps Python 3.9 users on 1.x). Client.fetch() is now async: replace client.fetch(url) with await client.fetch(url). fetch_sync() is deprecated, warns when called, and is planned for removal in 3.0.0.

Fictional example: use your own verified names, versions and behavior.

LogPulse

Generate release notes with LogPulse

LogPulse is a hosted changelog that people and coding agents can both read. Here's exactly what it can do for the steps above, and what it can't.

  1. Check your changelog, free

    Paste a CHANGELOG.md or a GitHub releases link. A deterministic parser (no AI, nothing stored) shows the versions, breaking changes, migration steps and deprecations an agent can extract.

    Open the converter

  2. Draft notes from GitHub

    Connect a repository: merged pull requests, commits to your default branch and published GitHub releases collect in an inbox. AI drafts a note in the tone you pick, listing breaking changes and deprecations only when the changes clearly show them. Nothing is published until you approve it. 3 AI drafts on Free; unlimited on Open source and Pro. Pro opens soon: .

    Free for open source

  3. Publish once, read everywhere

    Approved notes go to a hosted changelog and an in-app widget, to email subscribers on Open source and Pro (Pro opens soon), and to a JSON feed and MCP server coding agents can query. CI and agents can publish with an API key.

    Read the docs

What it doesn't do: LogPulse doesn't tag releases, bump versions, publish to npm or PyPI, or write CHANGELOG.md back to your repository; keep your release tooling for those. AI drafts can miss context, so review them like a pull request.

Before you publish

  • A Changelog URL is set under [project.urls]
  • The version is valid PEP 440 and matches the git tag
  • Pre-releases have their own entries
  • Yanked releases are marked [YANKED] with a reason
  • requires-python is raised when a Python version is dropped
  • Deprecated APIs emit a DeprecationWarning that names the replacement

Common questions

Should the changelog be Markdown or reStructuredText?

Either; GitHub renders both. Projects with Sphinx documentation often use reStructuredText so the changelog becomes part of the docs (pip's NEWS.rst is one example). Pick one and keep the headings consistent.

Does PyPI show my changelog?

PyPI shows your project description, usually the README, and the project links from your metadata. Link the changelog with a Changelog URL so people can find it from the package page.

What's the difference between yanking and deleting a release?

Yanking keeps the files available to exact pins but hides the release from normal resolution. Deleting removes it permanently, can break pinned installs, and the file names can't be reused.

How do coding agents find my Python changelog?

Usually through the repository. Clear version headings and Breaking and Deprecated sections make it parseable. Check what an agent would extract with the changelog-to-JSON converter, which also reads reStructuredText-style headings.

Sources

Facts last checked against these sources on .

More guides: open-source libraries and packages

All release notes guides

Tools and resources

LogPulse

Changelogs coding agents can upgrade from.

Publish versions, breaking changes, migration steps and deprecations once. People get a widget and hosted changelog; agents get a JSON feed and MCP server. Free for open source.