As a backend engineer, I have worked on many services where the hard part was not only writing the code. The hard part was keeping the project clean, reproducible, easy to build, and safe to maintain as the team and codebase grew.
In Go, a big part of that discipline comes from understanding Go Modules.
At first, Go Modules may look simple: a go.mod file, a go.sum file, and a few commands like go mod init and go mod tidy. But in real projects, these small tools decide how your service builds in CI, how your dependencies are verified, how private repositories are handled, and how other developers can use your package.
In this article, I want to explain Go Modules in a practical way, from the mindset of someone building production backend systems.
We will cover:
- What Go Modules are and why Go does not work like NPM or Pip
- How
go mod init,go mod tidy, andgo mod vendoractually help - How I think about Go project structure without over-engineering
- How to publish your own Go package
- A few production tips that matter in real teams
What Are Go Modules?
A Go module is a versioned collection of Go packages.
In simple words, it is the boundary of your project. It tells Go:
- what your project is called
- which Go version it targets
- which dependencies it needs
- which versions of those dependencies should be used
A Go module is defined by the go.mod file.
Before Go Modules, Go projects were commonly managed inside GOPATH. That worked, but it created friction around dependency versions and project location. Go Modules solved that by making dependency management explicit and project-based.
Today, when I start a serious Go project, one of the first things I do is initialize a module.
Why Go Packages Feel Different From NPM or Pip
If you come from JavaScript or Python, Go package management may feel a little strange at first.
In Node.js, packages are usually published to NPM.
In Python, packages are usually published to PyPI.
Go is different.
Go uses the module path as an import path, and that path is usually a version control URL, for example:
import "github.com/google/uuid"
This means Go can resolve packages directly from places like GitHub, GitLab, or Bitbucket.
That is why many Go packages look like URLs. The module path is not just a name. It is also the identity of the package.
This design makes publishing very simple. In many cases, publishing a Go package is just:
- push your code to GitHub
- create a proper Git tag
- let Go tooling resolve it
No separate package registry account is required for basic public modules.
Starting a Project With go mod init
Every Go module starts with this command:
go mod init <module-path>
For example:
mkdir my-awesome-project
cd my-awesome-project
go mod init github.com/yourusername/my-awesome-project
This creates a go.mod file:
module github.com/yourusername/my-awesome-project
go 1.22
The module path matters.
For local experiments, you can use something simple:
go mod init myapp
But for real packages, especially packages you may publish later, I prefer using the final repository path from the beginning:
go mod init github.com/yourusername/my-awesome-project
That prevents painful import-path changes later.
Adding Third-Party Packages
Let’s say I am building a small HTTP service and I want to use Gin:
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "pong",
})
})
if err := r.Run(); err != nil {
panic(err)
}
}
At this point, Go sees an external import:
"github.com/gin-gonic/gin"
Now the module needs to know about this dependency.
You can run:
go mod tidy
or directly:
go get github.com/gin-gonic/gin
In daily work, I use go mod tidy a lot because it does more than just add packages.
What go mod tidy Really Does
go mod tidy is one of the commands I run before almost every commit in a Go project.
It scans your code and tests, then updates go.mod and go.sum based on what is actually used.
It does three important things:
1. Adds Missing Dependencies
If your code imports a package but your module does not list it yet, go mod tidy adds it.
Example:
go mod tidy
After that, your go.mod may include something like:
require github.com/gin-gonic/gin v1.10.0
2. Removes Unused Dependencies
If you removed a package from your code but it is still listed in go.mod, go mod tidy cleans it up.
This is important because old dependencies are not harmless. They can increase build complexity, create security review noise, and confuse other engineers.
3. Updates go.sum
go.sum stores cryptographic checksums for module versions.
This helps Go verify that the dependency being downloaded is the same one expected by your project.
In production, this matters. Reproducible builds are not a luxury. They are part of engineering discipline.
My rule is simple:
If I changed imports, removed packages, upgraded dependencies, or touched tests, I run
go mod tidybefore committing.
Understanding go.mod and go.sum
A minimal go.mod file looks like this:
module github.com/yourusername/my-awesome-project
go 1.22
require github.com/gin-gonic/gin v1.10.0
This file is human-readable and should be committed.
go.sum is also committed. Some developers think go.sum is like a cache file and should be ignored. That is a mistake.
go.sum is part of dependency verification. It helps ensure that the same module versions resolve consistently across machines, CI pipelines, and production builds.
So in real Go projects, I commit both:
go.mod
go.sum
Vendoring Dependencies With go mod vendor
By default, Go downloads dependencies into the module cache on your machine.
But sometimes you want dependencies copied directly into your project. For that, Go provides:
go mod vendor
This creates a vendor/ directory and copies dependency source code into it.
Your project may look like this:
my-awesome-project/
├── go.mod
├── go.sum
├── main.go
└── vendor/
When Vendoring Makes Sense
I do not vendor dependencies in every project, but it can be useful in specific cases:
- CI/CD environments with restricted internet access
- companies with strict dependency review processes
- projects that must build in isolated networks
- teams that want dependency source code available inside the repository
For most normal public projects, vendoring is not required. Go’s module proxy and checksum database already solve many reliability problems for public dependencies.
But for enterprise systems, especially where builds must be predictable and auditable, vendoring can still be a valid choice.
If you use vendoring, build with:
go build -mod=vendor ./...
or test with:
go test -mod=vendor ./...
Project Structure: Start Simple
One of the mistakes I often see is copying project structures from other ecosystems.
A developer comes from Laravel, Django, NestJS, or Spring Boot and immediately creates folders like:
controllers/
services/
repositories/
models/
helpers/
utils/
managers/
This is not always wrong, but in Go it often becomes unnecessary complexity too early.
Go projects usually become cleaner when they start simple and grow based on real pressure.
For a small service, this can be enough:
my-project/
├── go.mod
├── go.sum
└── main.go
That structure is not “too simple”. It is honest.
You do not need architecture before you have a problem that needs architecture.
A Practical Production Structure
When the project grows, I usually move toward a structure like this:
my-project/
├── cmd/
│ └── api/
│ └── main.go
├── internal/
│ ├── config/
│ │ └── config.go
│ ├── database/
│ │ └── postgres.go
│ ├── http/
│ │ └── router.go
│ └── user/
│ ├── handler.go
│ ├── service.go
│ └── repository.go
├── pkg/
│ └── logger/
│ └── logger.go
├── go.mod
└── go.sum
Here is how I think about these folders:
cmd/
This contains application entry points.
For example:
cmd/api/main.go
cmd/worker/main.go
cmd/migrate/main.go
Each subdirectory represents a runnable program.
internal/
This is where most application code should live.
The important thing about internal/ is that Go enforces its privacy. Code inside internal/ cannot be imported from outside the parent module tree.
That is powerful.
It means your business logic, database code, handlers, and internal services are protected from becoming accidental public APIs.
pkg/
I use pkg/ only for code that is intentionally reusable by other projects.
For example:
- a logger package
- a small validation package
- a reusable client
- shared utilities that are stable enough to expose
I do not put everything into pkg/. If the code is only for this application, it belongs in internal/.
Publishing Your Own Go Package
One thing I really like about Go is how easy it is to publish packages.
Let’s say I wrote a small package for PostgreSQL migration helpers and I want other developers to use it.
Step 1: Create the Module
mkdir pgmigrate
cd pgmigrate
go mod init github.com/yourusername/pgmigrate
Step 2: Write a Small Package
package pgmigrate
import "database/sql"
type Migration struct {
Version int
Name string
Up string
Down string
}
func Apply(db *sql.DB, migration Migration) error {
_, err := db.Exec(migration.Up)
return err
}
Step 3: Add Tests
go test ./...
Even for small packages, tests matter. A package without tests is harder to trust.
Step 4: Push to GitHub
git add .
git commit -m "initial release"
git push origin main
Step 5: Tag a Version
Go Modules use semantic versioning.
Create a Git tag:
git tag v0.1.0
git push origin v0.1.0
Now other developers can use it:
go get github.com/yourusername/pgmigrate@v0.1.0
That is the beauty of Go package publishing. Git tags are releases.
Semantic Versioning in Go
Versioning is very important in Go.
A normal release tag looks like this:
v1.2.3
The meaning is:
MAJOR.MINOR.PATCH
-
PATCH: bug fixes -
MINOR: new backward-compatible features -
MAJOR: breaking changes
One Go-specific detail is important: if your module reaches v2 or higher, the module path must include the major version.
Example:
module github.com/yourusername/pgmigrate/v2
And users import it like this:
import "github.com/yourusername/pgmigrate/v2"
This may feel strange in the beginning, but it makes breaking changes explicit.
Working With Private Modules
In real companies, not all Go modules are public.
If your company has private repositories, you should configure GOPRIVATE.
Example:
export GOPRIVATE=github.com/mycompany/*
This tells Go not to use the public proxy or checksum database for those module paths.
For private GitHub modules, your machine or CI runner also needs Git authentication configured correctly, usually through SSH keys or tokens.
In CI/CD, this is one of the first things I check when a build fails with private dependencies.
A common symptom is:
terminal prompts disabled
or:
repository not found
The dependency may exist, but the CI runner does not have permission to access it.
Local Multi-Module Development With go work
Sometimes I work on two Go modules at the same time.
For example:
workspace/
├── api-service/
└── shared-lib/
Before Go workspaces, developers often used replace in go.mod:
replace github.com/mycompany/shared-lib => ../shared-lib
This works, but it is easy to accidentally commit local paths.
A better option for local development is go work.
Example:
mkdir workspace
cd workspace
go work init ./api-service ./shared-lib
This creates a go.work file that connects local modules during development.
I like this approach because it keeps local development clean without polluting the module definition.
My Production Checklist for Go Modules
Before I push Go code, I usually check these things:
go fmt ./...
go test ./...
go mod tidy
For larger services, I also check:
go vet ./...
And if vendoring is used:
go mod vendor
go test -mod=vendor ./...
This small routine prevents many annoying problems in CI.
My practical checklist is:
- commit both
go.modandgo.sum - run
go mod tidybefore pushing - avoid unnecessary dependencies
- do not expose internal application code as public packages
- use
GOPRIVATEfor private company modules - tag releases properly when publishing packages
- be careful with
replacedirectives before committing
Common Mistakes I See
Here are some mistakes I have seen many times in real projects.
Mistake 1: Ignoring go.sum
Do not ignore go.sum.
It is part of module verification and should be committed.
Mistake 2: Creating Too Many Folders Too Early
A complex folder structure does not automatically make a project clean.
In Go, simple structure is usually better until the codebase proves it needs more separation.
Mistake 3: Putting Everything in pkg/
pkg/ should not mean “all packages”.
Use it for code you intentionally want other modules to import. Otherwise, prefer internal/.
Mistake 4: Forgetting Private Module Configuration
If your dependency is private, the build environment must know that.
Use GOPRIVATE and make sure Git authentication works in CI.
Mistake 5: Committing Local replace Paths
This one is very common.
A local replace like this can break everyone else’s build:
replace github.com/mycompany/shared-lib => ../shared-lib
Use go work for local multi-module development when possible.
Final Thoughts
Go Modules are not just a dependency tool. They are part of how Go projects stay clean, buildable, and maintainable.
For me, the main lesson is this:
Keep the module simple, keep dependencies intentional, and let the project structure grow only when the project really needs it.
Start with go mod init.
Use go mod tidy as a habit.
Understand when vendor/ is useful.
Use internal/ to protect your application code.
Tag releases properly when you publish packages.
These small habits make a big difference when your Go project moves from a local experiment to a production service used by real users.
Happy coding.
Top comments (0)