Migrating from changesets

If you already run JS changesets, the model transfers directly. Molt keeps the changeset file, the two-phase workflow, and most config keys, and changes what Python's packaging rules give a different answer to.

The muscle memory carries over: you still add a changeset per change and version in a batch at release time. What differs are the places where PyPI and PEP 440 work differently from npm and SemVer, plus a few subsystems that have no Python meaning. This guide maps the concepts and lists the concrete differences. For a feature-by-feature table, see Molt and changesets.

What transfers unchanged

  • Changeset files. molt reads the same .changeset/*.md format -- YAML front matter mapping package names to bump types, then a Markdown summary. Your existing changesets carry over; the only substantive change is that names are matched using PEP 503 normalization, so Acme_Core matches acme-core.
  • The workflow. add -> version -> publish is the same loop, with the same "add intent while it is fresh, consume it in a batch" split.
  • Config keys, in camelCase. molt's config lives in [tool.molt] in pyproject.toml, but it accepts your changesets keys via aliasing. baseBranch, updateInternalDependencies, fixed, linked, ignore are all understood alongside their snake_case spellings (base_branch, and so on), so you can port most of a config table by pasting it. The keys molt does not carry have to be removed first -- see Your config needs editing before it loads. See also The config file.
  • fixed and linked groups, status, --since, dry runs. All present, with the same semantics.

What changed, and why

Versions and prereleases follow PEP 440

molt does version math with the packaging library -- the PyPA reference implementation of PEP 440 and PEP 508 -- not SemVer. Bump types are still major/minor/patch, but:

  • A prerelease is 1.0.0rc0, 1.0.0a1, or 1.0.0.dev3 from a fixed vocabulary, not an arbitrary 1.0.0-next.0 tag.
  • Range matching uses PEP 440 prerelease scoping, so a dependent that already opted into prereleases is not force-released as its dependency moves rc0 -> rc1. This is a deliberate divergence from node-semver; see Versioning and PEP 440.

No pre.json -- prerelease is a flag

Entering prerelease mode in changesets writes a pre.json that becomes repository-wide branch state. Molt has no such file. Prerelease is an invocation flag:

# changesets
changeset pre enter next
changeset version
# ... later ...
changeset pre exit
changeset version

# molt
molt version --pre rc     # cut release candidates this run
molt version              # back to normal -- no state to exit, nothing to clean up

There is no mode to enter, no file to commit, and no branch to unblock. A named channel such as next has no PEP 440 spelling, so map yours onto a, b, rc, or dev. See Prerelease mode and molt pre.

Snapshots target a non-PyPI index

On npm a snapshot is a throwaway version hidden behind a dist-tag. PyPI versions are permanent and it has no dist-tags, so every snapshot there would burn a public version number for good. Molt builds snapshots as PEP 440 .devN versions and targets a separate index by default: a wheelhouse, TestPyPI, or a private index. Publishing one to PyPI needs an explicit opt-in. See Snapshot releases.

molt yank replaces "unpublish"

PyPI has no unpublish. The recovery for a bad release is PEP 592 yanking: the release stays installable for anyone already pinned to it, but resolvers stop selecting it. Molt makes this a verb, molt yank, which checks the version and prints the exact steps. PyPI publishes no yank API, so the final click happens in your browser.

tag is now git-tag

changesets renamed tag to git-tag in v3 because "tag" collided with npm dist-tags. molt uses git-tag from the start. See molt git-tag.

peerDependencies are gone

Python has no peerDependencies, so molt drops the concept, along with the experimental onlyUpdatePeerDependentsWhenOutOfRange flag. Extras (foo[bar]) are the nearest analogue and behave like ordinary dependencies.

Your config needs editing before it loads

This is the one step of the migration that is not a paste, and it is worth doing first because nothing else runs until it is done. Molt refuses a configuration it cannot fully honour, naming the key or the value and what to write instead. It does not warn and carry on: a setting molt silently ignored would be a release molt got wrong, and by the time you read the warning the version is in a manifest and possibly on an index.

Run molt doctor here. It reports every rejected key and value in one pass, each naming the replacement, so you edit the file once instead of once per failed run. It also names every package your workspace has and tells you which ones a release would skip -- worth reading before your first molt version, because a package with no version, or a version derived from a git tag, is skipped and is invisible until a release is already going.

The keys a changesets configuration may carry that molt does not accept:

In .changeset/config.jsonWhat to write instead
accessRemove it. PyPI has no per-package publish permission; choose an index with molt publish --repository.
prettierRemove it. changesets 3.0 already replaced it with format, and molt's format never names a Node tool.
onlyUpdatePeerDependentsWhenOutOfRangeRemove it. Python has no peer dependencies.
___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCHKeep only its updateInternalDependents member, which molt promotes to a plain top-level option. Remove the wrapper and anything else inside it.
privatePackages.tagRemove it. Tagging is a molt git-tag decision per run, not a config option.
changelogTemplate / changelogDateschangelog = { template = "...", dates = true }. These were molt's own keys briefly, so an early molt config needs the same edit.

And one value, which is easy to miss because it is not a key at all:

ValueWhat to write instead
format: "auto", "prettier", "oxfmt", "deno", "dprint", "biome"format = "mdformat", format = false, or delete the line.

That last row catches configurations that did nothing unusual: a stock changesets 3.0 setup carries format: "auto", so a straight paste fails. Every one of those backends is a Node program, and molt shells out to no Node, so accepting the value would mean promising formatting and doing nothing. Deleting the line is the right answer for almost everybody, because molt emits correct Markdown directly.

$schema is unaffected: molt accepts it, strips it, and says nothing. It drives editor autocomplete and is not a setting.

Molt also refuses combinations that cannot mean anything: changelog = { generator = false, template = "..." }, an unrecognised {placeholder} in snapshot.prerelease_template, an empty base_branch, and an empty changed_file_patterns list. None of these has a changesets counterpart to migrate; they are listed in the options reference.

What still only warns is a glob that matches nothing today: an ignore entry or a fixed / linked member naming a package that does not exist yet. That is a fact about your workspace at this moment, not a mistake in the file.

What molt does differently, at a glance

changesetsmoltWhy
SemVer + node-semverPEP 440 + PEP 508 via packagingPython is not SemVer
1.0.0-next.0 prerelease tags1.0.0rc0 / a1 / .dev3 (fixed vocabulary)Arbitrary tags are illegal under PEP 440
pre.json modemolt version --pre <kind> flagPEP 440 has no arbitrary tags to persist
--snapshot -> npm dist-tag.devN on a separate indexPyPI versions are permanent and it has no dist-tags
unpublish / deprecatemolt yank (guided)PyPI has no unpublish
tag commandgit-tag commandchangesets renamed it in v3; molt starts there
peerDependencies bump rulesdroppedNo Python analogue
package.json (JSON, no comments)pyproject.toml (TOML, comment-preserving)Molt round-trips your comments and formatting
No lockfile stepuv.lock updated during versionA stale Python lockfile breaks --frozen CI
Formatter pass (Prettier/dprint)Correct Markdown emitted directlyMolt shells out to no Node toolchain
GitHub-only automationHost calls behind a forge protocolThe core loop already runs on any CI

Seeding changesets from your history

Adopting molt mid-project leaves you with commits that predate any changesets. molt offers a one-time seeding step that proposes changeset files from your recent commit history, which you then edit and commit:

molt add --from-commits <since-ref>

This is a migration aid. Molt does not derive versions from commit messages on an ongoing basis, because a commit is not a release intent and a prefix convention cannot see a breaking change that crosses a package boundary. Seeding gets you a starting pile of changesets; from there, the normal molt add flow takes over. See The changeset workflow.

A suggested migration order

  1. Move .changeset/config.json settings into [tool.molt] (camelCase keys are accepted, so this is mostly a paste) and remove the keys and the format value molt refuses -- see Your config needs editing before it loads. Run any molt command to check: it names every remaining problem in one pass.
  2. Confirm the ecosystem backend discovers your workspace members.
  3. Keep any pending .changeset/*.md files; molt reads them as-is.
  4. Replace pre enter/pre exit habits with molt version --pre.
  5. Repoint snapshot publishing at a non-PyPI index.
  6. Optionally seed changesets for in-flight work with molt add --from-commits.

Where to go next