A go.mod file tells you what your project depends on. It cannot tell you which of those dependencies the standard library has already made unnecessary.
That gap is bigger than it sounds. Go 1.13 shipped %w and github.com/pkg/errors became largely redundant. Go 1.21 shipped slices, maps, cmp and log/slog. Go 1.22 taught net/http.ServeMux method and wildcard routing. Go 1.27 shipped uuid. Every one of those releases quietly demoted a package that thousands of go.mod files still require.
Almost nobody goes back and removes them. Not out of laziness — because doing it safely means auditing which symbols you actually use, and whether the standard library's version really behaves the same. That's mechanical, tedious, high-stakes work. So I built a tool for it, for the Zero Dependency Hackathon 2026, Track A.
I went in believing this was an import-rewriting problem.
I was wrong, and the way I was wrong is the interesting part.
molt finds the dependencies Go's standard library has already replaced, and rewrites the ones it can prove are safe. It has no third-party dependencies. Its
go.modhas norequireblock at all.
Here's the tool, the proof, and the build, in five minutes:
First: a static-analysis tool that can't use x/tools
The hackathon's rule for Go is unusually sharp:
stdlib only.
go.modhas no require block (the toolchain andgolang.org/xare not a free pass, stdlib means stdlib).
That last clause is the whole game. Every Go static-analysis tool — every linter, every code generator, every language server — loads source through golang.org/x/tools/go/packages. It is the canonical answer and it is excellent. It is also not the standard library.
molt's core question is: which exported names of package P does this file reference?
I assumed that needed type resolution, which meant go/packages, which meant the project was impossible under the rules. Then I actually looked at what I was asking for.
Import declarations and selector expressions are both syntax. They're already in the parse tree.
af, _ := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution)
for _, spec := range af.Imports {
// local name -> import path
}
ast.Inspect(af, func(n ast.Node) bool {
if sel, ok := n.(*ast.SelectorExpr); ok {
if id, ok := sel.X.(*ast.Ident); ok {
// id.Name qualifies sel.Sel.Name
// e.g. "uuid" qualifies "New"
}
}
return true
})
That's it. That's the analysis core. go/parser, go/ast, go/token, go/format — all standard library, all shipped with the compiler you already have.
Go's standard library contains a Go parser. That is not a coincidence or a curiosity. It is what a good standard library is for, and it's the only reason this project could exist under the constraint.
So the tool whose job is removing dependencies from Go projects turned out not to need any. I'd like to claim I planned the symmetry.
Then: the part I got wrong
Here's the naive model I started with.
import "golang.org/x/exp/slices" → import "slices"
Same package name. Same function names. Swap the path, done.
Now look at what actually changed between those two packages:
// golang.org/x/exp/slices
slices.SortFunc(items, func(a, b Item) bool {
return a.Score < b.Score // less(a, b) bool
})
// standard library slices
slices.SortFunc(items, func(a, b Item) int {
return cmp.Compare(a.Score, b.Score) // cmp(a, b) int
})
The comparator's return type changed from bool to int.
Swap only the import and pass the old closure, and Go's type checker will often accept it — a bool-returning closure is a compile error, but the failure mode people actually hit is subtler: code that was written against one convention and mechanically moved to the other. false is not 0. A comparator that returns bool-ish semantics through an int signature sorts your data into the wrong order.
It compiles. It runs. It's wrong. No panic, no error return, no log line. Just quietly incorrect ordering somewhere downstream.
That was the moment the project changed shape. The dangerous part of dependency migration isn't finding packages with matching names. It's deciding whether two APIs are behaviourally equivalent — and names are almost no evidence for that.
The trap table
Once I started looking for these, they were everywhere. Each row is pinned by a test in the repo:
| Looks like a rename | What actually changed |
|---|---|
x/exp/slices.SortFunc → slices.SortFunc
|
Comparator went from less(a,b) bool to cmp(a,b) int. Compiles, then sorts wrongly.
|
x/exp/slices.SortStable → slices.SortStable
|
Doesn't exist. The stdlib only has SortStableFunc. Fails to compile. |
x/exp/maps.Keys → maps.Keys
|
Return type went from a slice to an iter.Seq. Needs slices.Collect. |
google/uuid.Nil → uuid.Nil
|
A package variable in google/uuid, a function in the stdlib. Must become uuid.Nil(). |
google/uuid.NewRandom → uuid.NewV4
|
google returns (UUID, error); the stdlib returns UUID alone. The arity of the call site changes.
|
pkg/errors.Wrap(err, msg) |
Becomes fmt.Errorf("%s: %w", msg, err). The arguments swap places.
|
Look at uuid.Nil for a second. In github.com/google/uuid it's a package-level variable. In Go 1.27's uuid it's a function. So:
if id == uuid.Nil { } // google/uuid — comparing to a variable
if id == uuid.Nil() { } // stdlib — calling a function
I only found that because I ran go doc uuid against a real Go 1.27 toolchain instead of trusting a summary of the release notes. Signatures matter more than names when you're about to edit somebody else's code.
The bugs were in the migrations I thought were obvious
This is the section I'd skip if I were writing marketing copy, so it's the one worth reading.
After the first working version, I put the source through an automated code review. It came back with things I'd have sworn were fine. Eight of them were real, and fixing them made every headline number in my README smaller.
1. go-homedir — identical signatures, different behaviour
homedir.Dir() and os.UserHomeDir() both return (string, error). Byte-identical signature. I had it marked mechanical, and the tool rewrote it happily.
Then: go-homedir caches its first result by default. os.UserHomeDir reads the environment on every call.
For most code that difference is invisible. But go-homedir exports Reset() and DisableCache(), and code that calls either of those is code that depends on the caching. A file that only calls Dir() looks perfectly safe to rewrite in isolation — and if a sibling file in the same package calls Reset(), rewriting the first one silently breaks an assumption the package was built on.
molt decides eligibility per file, which is deliberate and mostly a feature: one awkward call site shouldn't disqualify eighty clean ones. But per-file analysis structurally cannot see across files. So this row can't be mechanical, and it's now advisory with a note explaining exactly why.
Signature equality is necessary for a mechanical rewrite. It was never sufficient.
2. pkg/errors.New — not a rename, a feature removal
I had this one wrong in the most embarrassing way, because it's the migration everyone assumes is trivial:
errors.New("boom") // pkg/errors — captures a retrievable stack trace
errors.New("boom") // stdlib — does not
Same call, same signature, same result type. pkg/errors.New attaches a stack trace you can retrieve later. errors.New doesn't. Same for pkg/errors.Errorf versus fmt.Errorf.
That's not a rename. It's removing a feature from a codebase that may be relying on it — and doing it invisibly, because nothing fails until someone goes looking for a stack trace that isn't there any more.
Of pkg/errors, only Is, As and Unwrap are genuinely drop-in. New and Errorf are now blocked. Wrap and Wrapf always needed hands.
The cost of being right: ory/kratos has 1,785 pkg/errors uses across 286 files. My earlier pass called 40 of them migratable. After this fix, 11.
3. The Go-version gate I'd never written
molt would happily rewrite github.com/google/uuid to the standard library's uuid — which landed in Go 1.27.
gofiber/fiber declares go 1.24. minio/minio declares go 1.25.
Rewriting their imports would have produced code that references a standard-library package their own declared toolchain floor doesn't provide. It wouldn't compile. I was generating broken code and calling it a migration.
The fix is a module-level veto that runs before any file is touched:
// Ineligible reports why a module-level fact makes m unsafe to apply
// automatically to mod, regardless of per-file symbol usage.
func Ineligible(mod *gomod.File, m corpus.Migration) string {
if mod != nil {
if rep, ok := mod.Replaced(m.Module); ok {
return fmt.Sprintf("go.mod replaces this module with %s; "+
"corpus verification does not apply to the replacement", rep.New)
}
}
if !gomod.GoVersionAtLeast(mod.GoVersion, m.Since) {
return fmt.Sprintf("requires %s; module declares go %s",
m.Since, mod.GoVersion)
}
return ""
}
A module with no go directive is treated as satisfying nothing above go1.0. An unknown floor can't be confirmed to be high enough, and guessing in the permissive direction generates code that doesn't build.
4. replace directives, and the prefix that nearly slipped through
If go.mod says:
replace golang.org/x/exp => ../our-fork
then the code behind golang.org/x/exp/slices is not the code my corpus verified. It could be a local fork with different behaviour entirely.
The subtlety: a replace operates on a module path, and a module contains many packages. That directive never mentions slices, but it redirects it. Matching import paths for equality misses it completely — you need the prefix too:
func (f *File) Replaced(importPath string) (Replace, bool) {
for _, r := range f.Replaces {
if r.Old == importPath || strings.HasPrefix(importPath, r.Old+"/") {
return r, true
}
}
return Replace{}, false
}
Which also meant writing a real replace parser — single-line and parenthesised block forms — where I'd previously just counted the directive and moved on.
5. Qualifier collisions, checked before mutating anything
An unaliased rewrite introduces a new qualifier at every call site: the target package's own name. If the file already binds that name — a variable called slices, or an import of the same path under a different alias — the rewrite corrupts the file.
I had shadowing detection. I didn't have this:
if q, imported := qualifierFor(af, t); imported && q != want {
return nil, fmt.Errorf("%s is already imported as %q in this file, "+
"which conflicts with the unaliased %q this migration needs", t, q, want)
}
And critically, that check now runs before a single AST node is mutated. The earlier version could bail halfway through and leave a file with some selectors renamed and some not — worse than either outcome.
6. A stale snapshot
rewrite collected the file's existing imports once, up front, then applied migrations in a loop. But an earlier migration in that same loop can add or remove an import. Every subsequent migration was reasoning about a snapshot that was already wrong. Now it queries live.
7. Non-atomic writes
os.WriteFile truncates before it writes. A crash or a full disk mid-write leaves the user's source file truncated — the worst possible failure for a tool that edits code.
// writeFileAtomic writes data to path without ever leaving it half-written.
// Temp file in the same directory, sync, then rename — atomic on POSIX and
// Windows both, so a crash mid-write leaves the original intact.
func writeFileAtomic(path string, data []byte, mode os.FileMode) (err error) {
tmp, err := os.CreateTemp(filepath.Dir(path), ".molt-*.tmp")
// ... write, Sync, Close, Chmod ...
return os.Rename(tmpPath, path)
}
The temp file goes in the same directory on purpose, so the rename can't cross a filesystem boundary and silently degrade to a copy.
8. A swallowed error
A file that couldn't be read was logged to stderr and skipped, and the run still exited 0. So -apply could report success having silently skipped half your files. Read failures now fail the run.
Nine tests, one review
Every one of those fixes has a test that fails if it regresses: TestVersionGateBlocksNewerMigration, TestReplaceDirectiveVetoesMigration, TestRefusesTargetQualifierCollision, TestRefusesReuseOfIncompatibleQualifier, TestRewriteReportsReadFailures, TestApplyWritesAtomicallyAndCleansUp, and an expanded TestTrapsArePinned that now pins pkg/errors.New/Errorf and slices.SortStable as blocked.
The corpus went from 6 mechanical rows to 5. ory/kratos went from 40 migratable files to 11. minio/minio and gofiber/fiber each lost their google/uuid migration to the version gate.
Every number got worse, and the tool got correct. If you're building anything that edits source code, that trade is not close.
What molt refuses to do
Which brings me to the design principle I'd defend hardest:
Automation should stop when confidence stops.
molt edits source code, so the interesting question isn't what it can do. Every migration in the corpus is one of two kinds:
- Mechanical — verified behaviour-preserving at every call site it permits, symbol by symbol. molt rewrites these. There are 5, out of 24 rows.
-
Advisory — the migration is real, but it changes the shape of the code rather than its names.
logrus.WithFields(...)toslogattributes. Agorilla/muxroute table toServeMuxpatterns. molt explains it and leaves it alone.
On top of that, molt declines to touch a file when:
file imports a corpus module
│
┌─────────┴─────────┐
mechanical? advisory ──▶ explain, don't touch
│
dot import? ──────────yes──────────▶ REFUSE
│ (selectors unattributable)
package name shadowed? ────yes──────────▶ REFUSE
│ (might rewrite wrong identifier)
qualifier collision / alias? ─yes──────────▶ REFUSE
│
every symbol in the table? ──no───────────▶ REFUSE
│ (no guessing)
go.mod version high enough? ─no──────────▶ REFUSE
│
replace directive? ──────yes───────────▶ REFUSE
│ (unverified code)
rewrite in memory
│
output re-parses & formats? ─no─────────▶ ABORT
│ (file left byte-identical)
WRITE
That last one matters more than it looks: molt parses its own output and refuses to write anything the parser rejects. A tool that puts unparseable Go into your repository is worse than no tool.
And eligibility is decided per file rather than per module, because a project may use one awkward symbol in one place and clean ones in eighty others. That's why reports say things like "21 of 29 files" rather than a yes/no.
The refusals aren't hypothetical. Running against 12 production repositories, the dot-import defence fired on sirupsen/logrus and the shadowing defence fired on spf13/viper — real code, not fixtures. docker/cli imports pkg/errors, but only inside vendor/, which molt skips exactly as the go command does; it correctly reported nothing.
Proving zero dependencies
Plenty of projects claim no dependencies. The claim is worth more if a reader can falsify it in one command.
Here's molt's entire go.mod:
module molt
go 1.25
No require block. No go.sum file. No vendor/ directory.
And the check anyone can run:
go list -deps ./... | grep -v '^molt' | awk -F/ '$1 ~ /\./'
In plain English: list every package in the build, drop molt's own, and show me anything left that looks like it came from the internet.
The technical version: go list -deps prints the full transitive package graph. Every module path outside the standard library begins with a domain name, so a dot in the first path element is a reliable test for "not stdlib". fmt has no dot. go/ast has no dot. github.com/anything does.
The output is empty. The build is 91 packages: 83 standard library, 8 of molt's own, 0 third-party.
That's the same test goimports uses internally to sort standard-library imports into their own group, which I found out when I had to reimplement import grouping — gofmt doesn't group imports, and goimports is a separate binary, not a library I could call.
The 14 packages I didn't install
The repo's STDLIB.md documents every substitution with what got harder and what tradeoff was accepted. A few that were more interesting than expected:
| Instead of | I used | The catch |
|---|---|---|
x/tools/go/packages |
go/parser + go/ast
|
No type resolution. Handled by refusing ambiguous cases, not resolving them. |
x/tools/go/ast/astutil |
direct *ast.GenDecl edits |
go/printer only emits parentheses when Lparen holds a valid position
|
x/mod/modfile |
~200 lines of hand-written parser | Quoted paths, // indirect followed by other words, block directives |
sergi/go-diff |
an LCS line differ | O(n×m) memory — fixed by trimming common prefix/suffix first |
spf13/cobra |
flag |
Lost shell completion. molt takes one path and seven booleans. |
stretchr/testify |
testing |
More typing — and better failure messages, unexpectedly |
Masterminds/semver |
nothing | I never actually needed to compare versions |
That last row is my favourite. I assumed reporting "stdlib since go1.21" meant comparing versions. It didn't — Since was just a display string, and the decision molt makes depends on the corpus, not version arithmetic. The most valuable substitution is the one where you realise you didn't need the capability at all.
(Ironically, the Go-version gate from the code review later did need version comparison. It's 30 lines of strings.SplitN and strconv.Atoi, because go.mod's go directive has only ever gated stdlib availability at minor-version granularity. Still not a semver library.)
The edge case that ate an afternoon
Two lines of go/printer behaviour, and I want to be specific about it because it's the kind of thing you cannot find by reasoning — only by staring at wrong output.
molt was rewriting slices.Sort(s) correctly, in the sense that the AST was right and the code compiled. It printed like this:
slices.
Sort(s)
Every rewritten call site, split across two lines. Valid Go. Completely unacceptable — nobody accepts a patch that looks like that.
I assumed I'd broken the selector expression. I hadn't. The AST was perfect. The problem was the positions.
Here's what I'd written:
c.sel.X = ast.NewIdent(pkg) // replace the qualifier node
ast.NewIdent creates an identifier carrying token.NoPos — position zero. And go/printer doesn't lay out from structure alone; it reads the gap between a node's recorded position and the next one to decide where line breaks go. A zero-position qualifier followed by a selector at its real position in a 400-line file looks, to the printer, like an enormous vertical gap. So it inserts a newline.
The fix is one character of difference in intent:
c.sel.X.(*ast.Ident).Name = pkg // mutate the existing node's Name
Don't replace the node. Reach into the node that's already there and change its Name field, so the original position survives untouched.
Two lessons I'd have paid to learn faster:
-
go/astnodes are not pure data. They carrytoken.Posfields that the printer treats as layout instructions. Synthesising a node is not the same as editing one, and the difference doesn't show up until you print. -
This is exactly the class of problem
x/tools/go/ast/astutilexists to hide. Not having it meant learning why it exists. That afternoon was the single clearest illustration of what the zero-dependency constraint actually costs — and what it teaches.
Its sibling, from the same afternoon: go/printer only emits parentheses around an import block when GenDecl.Lparen holds a valid position. A single-line import "x" that gains a second spec prints as one broken line unless you promote it first:
if !gen.Lparen.IsValid() {
gen.Lparen = gen.TokPos + token.Pos(len("import"))
gen.Rparen = gen.Lparen
}
That's a fabricated position, and fabricating positions is fragile enough that I stopped doing it for anything larger. It's why import grouping is done by splicing bytes into the printed output rather than by manipulating the tree — forcing a blank line between two specs through go/printer means inventing token positions, and I'd already learned what happens when you get those wrong.
Reproducible builds, for the same reason
If the point of the project is removing hidden machinery, the build itself should be inspectable. make repro builds twice, clears the build cache in between, and compares SHA-256:
| Target | SHA-256 |
|---|---|
windows/amd64 |
89f9e03a4b0239a010ceece14535ec13a6a0fcb0bb4569da5828b3292fcddba4 |
linux/amd64 |
c59a5c5edb7003e7bef837fae717504789740b83bd87e2a21a324635c5e69852 |
darwin/arm64 |
2c08a9a71dabe63435be289281f81dfffe2da82ac7e45e06bc607435bacb81a5 |
Go builds are not byte-identical by default. Three things break it:
-
Absolute source paths get embedded →
-trimpath -
Since Go 1.24, the toolchain stamps VCS information into the binary — commit hash and dirty flag change the bytes →
-buildvcs=false. This is the one most people miss. -
The build ID varies →
-ldflags "-buildid="
Plus CGO_ENABLED=0 to keep the host C toolchain out, and a pinned GOTOOLCHAIN so a different Go version can't silently change the output.
molt also embeds no build timestamp and no commit hash. A version string that changed every build would be worth less than a reproducible artifact.
What it actually looks like
$ molt testdata/tidy-app
molt github.com/example/tidy
Go files scanned 2
Direct requires 3
Indirect requires 0
REMOVABLE molt can apply these in full
github.com/google/uuid -> uuid
stdlib since go1.27 · 1 symbol, 1 use, 1 file
New
golang.org/x/exp/slices -> slices
stdlib since go1.21 · 3 symbols, 3 uses, 1 file
Compact, Contains, Sort
golang.org/x/net/context -> context
stdlib since go1.7 · 2 symbols, 4 uses, 2 files
Background, Context
3 removable · 0 partly removable · 0 need a human · 0 unused
corpus: 24 rows, 5 mechanical
molt -diff . prints the patch without writing anything:
import (
+ "context"
"errors"
"fmt"
"path/filepath"
-
- "github.com/google/uuid"
- "golang.org/x/exp/slices"
- "golang.org/x/net/context"
+ "slices"
+ "uuid"
)
Note that the import block comes back regrouped stdlib-first — that's the hand-rolled grouping, since gofmt won't do it.
molt -apply . writes, then tells you the next two commands. It never edits go.mod itself:
Rewrote 3 files. Run go mod tidy to drop the requires, then go test ./... to confirm.
Rewriting the manifest is the go command's job and it does it better. -exit-code follows the gofmt -l convention so CI can fail on findings; plain molt . exits 0 even with findings, because reporting is not failing.
Limitations, stated plainly
These matter more than the feature list.
- Go only. The whole idea depends on the standard library shipping a parser.
- No type checking. molt matches import declarations against selector expressions, and handles the cases where that's insufficient by refusing them. A type-aware version would migrate more files and be a much larger tool.
-
Shadowing detection is file-wide, not scope-aware. If a file binds
slicesanywhere, the whole file is unsafe. This over-reports and costs molt rewrites it could have made. The opposite error corrupts code. -
Build-tagged files aren't excluded. molt reads every
.gofile regardless of constraints, which is why the "unused dependency" finding is worded as a prompt to look, not a verdict. - molt never edits
go.mod. -
The corpus is hand-written and finite — 24 rows. It will miss dependencies it's never heard of.
molt -corpusprints exactly what it knows. -
Mechanical wins are rarer than 24 rows suggests, and rarer still after the version gate. Well-maintained repos have mostly already left
x/exp/slicesandx/net/context.google/uuidis the most promising row and needs Go 1.27 — released days before this event — so most real modules don't qualify yet. That gate is doing its job.
And the one that matters most: molt does not claim your tests will pass after -apply. It claims the edit is behaviour-preserving for the symbols it permits, and that you should run your tests. Which is why the command tells you to.
What I actually learned
I set out to build an import rewriter and ended up building a confidence classifier.
The code that decides whether to rewrite is now larger and more interesting than the code that does the rewriting. That inversion happened because of the traps — SortFunc's comparator, uuid.Nil's variable-to-function change, pkg/errors quietly dropping stack traces, go-homedir caching where the standard library doesn't. Every one of them looks like a rename. None of them is.
The constraint helped more than it hurt. Not having go/packages meant I couldn't resolve my way out of ambiguity, so I had to classify it instead — and the refusals turned out to be the most valuable thing in the tool. A type-aware version would migrate more files. I'm not sure it would have taught me that.
And the code review that made every number smaller was the best thing that happened to the project. It's an easy principle to state and a hard one to accept while you're watching "40 migratable files" become "11".
A dependency isn't automatically bad. But a dependency the platform has already replaced is worth questioning — and the goal was never to reach zero. It was to make the decision deliberate.
Your go.mod is a record of the last time you checked what the standard library could do. Mine is three lines long, and I can prove it in one command.
Code: github.com/PrinceXDev/molt — MIT, go build -o molt ./cmd/molt, no downloads.
Demo film: five minutes, all real output.
Hackathon: Zero Dependency 2026, Track A — Developer Tools & CLI.
If you work on Go tooling, or you've hit the go/printer position problem yourself, I'd genuinely like to hear how you handled it — Prince Panchani on LinkedIn.
Written for the Zero Dependency 2026 Write-Up side quest. Thanks to Hackathon Raptors for running an event whose central constraint turned out to be a design tool.

Top comments (2)
This is a useful case for making the migrator’s confidence observable. For every proposed rewrite, emitting the matched rule, the behavior it assumes, the package-wide facts it did and did not inspect, and a suggested regression test would let a maintainer review risk rather than accept a binary “safe.” That is especially valuable for silent semantic changes.
Thanks, @alexshev — I completely agree. That’s exactly the direction I want to take with
molt: make the reasoning behind each rewrite observable, not just label it as “safe.” Showing the matched rule, assumed behaviour, inspection boundaries, and a suggested regression test would make the migration decisions much easier to review, especially for changes where the code still compiles but the behaviour can silently change.