You pushed the code, but nothing happened
You wrote a utility that parses configuration files. It handles edge cases. You commit it to a public repository on GitHub. You tell a colleague to import it. They run go get github.com/you/configparser. The command fails. The error says the module doesn't exist. You stare at the screen. The code is public. The repository is there. The problem isn't the code. The problem is the version.
Go doesn't treat a repository as a package. Go treats a repository as a potential source for a module. A module is a collection of packages rooted by a go.mod file. The module system needs a version to lock your code in time. Without a version, the module system has nothing to grab. Your code is just text in a folder. A version turns it into a dependency.
Publishing a Go package means creating a version tag in Git and pushing it. The tag tells the world that a specific commit is stable enough to be imported. The Go toolchain uses that tag to fetch the code, verify its integrity, and make it available to other projects.
Modules, packages, and the version contract
A package is a directory of Go files that share an import path. A module is a tree of packages described by a go.mod file. When you publish, you are publishing a module. Users import packages from that module.
The go.mod file defines the module path. This path is the URL users type when they import your code. It usually starts with a domain you control. The path must match the repository URL. If your repo is github.com/you/configparser, your module path should be github.com/you/configparser.
Versions follow semantic versioning. A version string looks like v1.0.0. The v prefix is mandatory. The Go tool rejects tags without it. The numbers represent major, minor, and patch releases. Major versions indicate breaking changes. Minor versions add features in a backward-compatible way. Patch versions fix bugs.
Tags are the only truth. The go command ignores your main.go, your untagged commits, and your branch names. It looks for tags. If you push code without a tag, the module system cannot see it. You can have a hundred commits on main. If there is no tag, there is no version. If there is no version, there is no module.
Minimal example: tagging a module
Here is the simplest workflow. You have a repository with a go.mod file. You want to publish version 1.0.0.
Create the go.mod file if you don't have one. Run go mod init github.com/you/configparser in your terminal. This generates the file with the correct module path.
// module declares the module path. This path must match the import path users will type.
// The path usually starts with a domain you control.
module github.com/you/configparser
// go specifies the minimum Go version required to build this module.
// The toolchain uses this to enforce language features and standard library changes.
go 1.21
Commit the file. Then create the tag and push it.
# Tag the current commit with the version string.
# The v prefix is required by the Go module system.
git tag v1.0.0
# Push the tag to the remote repository.
# Tags are not pushed by default with git push.
git push origin v1.0.0
After you push the tag, the Go module proxy detects it. The proxy fetches the tarball, calculates a hash, and stores it. Within a few minutes, pkg.go.dev generates documentation. Users can run go get github.com/you/configparser@v1.0.0 to import your code.
Tags are the only truth. Push the tag, or the module doesn't exist.
How the proxy and checksum database work
The go command doesn't talk to GitHub directly when fetching dependencies. It talks to the Go module proxy. The proxy is a service that caches module versions. When you run go get, the tool asks the proxy for the module. The proxy fetches the tarball from your repository, calculates a cryptographic hash, and stores it. The hash goes into the checksum database.
This design prevents supply chain attacks. If someone compromises your GitHub account and pushes malicious code to an existing tag, the proxy won't serve the new content. The hash won't match. The build fails. The checksum database ensures that the code you get is exactly the code that was verified when the version was first published.
The proxy also handles availability. If your repository goes down, the proxy can still serve cached versions. This makes builds more reliable. You can configure the proxy URL with the GOPROXY environment variable. The default is https://proxy.golang.org,direct. The direct fallback allows the tool to fetch directly from the repository if the proxy doesn't have the version yet.
The proxy verifies, it doesn't trust. Always check your checksums.
Writing a publishable library
Publishing code exposes it to the world. Your API becomes part of other people's builds. A breaking change can break downstream projects. A panic in your library can crash a server. Writing a publishable library requires discipline.
Follow Go conventions. Public names start with a capital letter. ParseConfig is visible to importers. parseHelper is hidden. This is how Go handles visibility. There are no public or private keywords. The compiler enforces the capitalization rule.
Document your public API. Add a comment above every exported function, type, and variable. The comment must start with the name of the declaration. gofmt is mandatory. Don't argue about indentation; let the tool decide. Most editors run it on save. Your code should be formatted before you commit.
// ParseConfig reads a configuration file and returns a Config struct.
// It returns an error if the file cannot be read or parsed.
// The function respects the context for cancellation.
func ParseConfig(ctx context.Context, filename string) (Config, error) {
// context.Context always goes as the first parameter.
// Functions that take a context should respect cancellation and deadlines.
select {
case <-ctx.Done():
return Config{}, ctx.Err()
default:
}
// Open the file.
// If err != nil { return err } is verbose by design.
// The community accepts the boilerplate because it makes the unhappy path visible.
file, err := os.Open(filename)
if err != nil {
return Config{}, fmt.Errorf("open config: %w", err)
}
defer file.Close()
// Decode the JSON.
var cfg Config
if err := json.NewDecoder(file).Decode(&cfg); err != nil {
return Config{}, fmt.Errorf("decode config: %w", err)
}
return cfg, nil
}
Accept interfaces, return structs. This is the most common Go style mantra. If your function needs to read data, accept an io.Reader. If it needs to write, accept an io.Writer. This makes your code flexible. Don't pass a *string. Strings are already cheap to pass by value. Passing a pointer to a string adds indirection without saving memory.
The receiver name is usually one or two letters matching the type. (c *Config) Validate() is correct. (this *Config) or (self *Config) is not idiomatic. Use _ to discard a value intentionally. result, _ := ... says "I considered the second return value and chose to drop it". Use it sparingly with errors. Discarding an error silently is a bug waiting to happen.
Goroutine leaks happen when the goroutine waits on a channel that never gets closed. If your library starts background goroutines, provide a way to stop them. Always have a cancellation path. The worst goroutine bug is the one that never logs.
Versioning is a contract, not a suggestion. Respect your users.
The v2 path quirk
Semantic versioning says major versions can break compatibility. Go handles this with a twist. If you release a v2 of your module, the module path must change. The path becomes github.com/you/configparser/v2.
This is required because the go command uses the module path to distinguish versions. If you keep the same path, the tool assumes v2 is part of the v0/v1 series. It won't work. The /v2 suffix tells the tool that this is a new major version with a different API.
Users import the v2 module with the suffix. They write import "github.com/you/configparser/v2". The go.mod file must also include the suffix.
// module path includes the /v2 suffix for major version 2.
// This tells the toolchain that this module has breaking changes.
module github.com/you/configparser/v2
go 1.21
You can have v1 and v2 in the same repository. They coexist. The v1 tags live on the main branch. The v2 tags live on a v2 branch. The tags are v1.0.0, v1.1.0, v2.0.0, v2.1.0. The go.mod file in the v2 branch has the /v2 suffix. The go.mod file in main does not.
Don't fight the type system. Wrap the value or change the design. Use the v2 path when you break the API.
Pitfalls and debugging
Publishing can fail in subtle ways. Here are common problems.
If your go.mod path doesn't match the repository URL, the proxy rejects the module. You get a module path mismatch error during go get. The tool compares the path in go.mod with the import path derived from the URL. They must align.
If you forget to capture the loop variable, the compiler rejects the program with loop variable i captured by func literal (which became a hard error in Go 1.22+). This error appears during build, not during publish. But if your library has this bug, users will see it. Fix it before you tag.
Never publish a module with replace directives. Replace directives are for local development. They tell the tool to substitute one module with another. If you publish a module with a replace directive, it breaks the consumer's build. The proxy strips replace directives, but it's bad practice. Clean your go.mod before you tag.
Private repositories don't work with the public proxy. If your repo is private, go get fails unless the user has authentication configured. You can use a private proxy or configure GOPRIVATE. The public proxy cannot fetch private code.
The compiler complains with cannot use x (untyped int constant) as string value in argument if you pass the wrong type. This is a type error. It stops the build. Make sure your library compiles before you publish. Run go build ./... to check all packages.
Debugging go get failures often involves checking the proxy. Run go list -m -json github.com/you/configparser to see what the tool knows about the module. Check the Version and Time fields. If the version is missing, the proxy hasn't seen the tag yet. Wait a few minutes. If the error persists, check the tag name and the go.mod path.
Trust gofmt. Argue logic, not formatting.
Decision: when to publish and how
Use a public module when you want other projects to import your code via go get. Use a local replace directive when you are developing a library alongside a consumer project and need to test changes without publishing. Use a v2 module path when you make breaking changes to a public API. Use a private repository when the code contains secrets or proprietary logic that shouldn't be accessible to the public proxy. Use a single repository with multiple modules when you have distinct components with different release cycles, though this is rare and adds complexity. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.
Context is plumbing. Run it through every long-lived call site.