DEV Community

Cover image for Go Modules in Practice: Init, Tidy, Vendor, and Publishing Packages
amir
amir

Posted on

Go Modules in Practice: Init, Tidy, Vendor, and Publishing Packages

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, and go mod vendor actually 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"
Enter fullscreen mode Exit fullscreen mode

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:

  1. push your code to GitHub
  2. create a proper Git tag
  3. 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>
Enter fullscreen mode Exit fullscreen mode

For example:

mkdir my-awesome-project
cd my-awesome-project
go mod init github.com/yourusername/my-awesome-project
Enter fullscreen mode Exit fullscreen mode

This creates a go.mod file:

module github.com/yourusername/my-awesome-project

go 1.22
Enter fullscreen mode Exit fullscreen mode

The module path matters.

For local experiments, you can use something simple:

go mod init myapp
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

At this point, Go sees an external import:

"github.com/gin-gonic/gin"
Enter fullscreen mode Exit fullscreen mode

Now the module needs to know about this dependency.

You can run:

go mod tidy
Enter fullscreen mode Exit fullscreen mode

or directly:

go get github.com/gin-gonic/gin
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

After that, your go.mod may include something like:

require github.com/gin-gonic/gin v1.10.0
Enter fullscreen mode Exit fullscreen mode

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 tidy before 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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 ./...
Enter fullscreen mode Exit fullscreen mode

or test with:

go test -mod=vendor ./...
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Add Tests

go test ./...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now other developers can use it:

go get github.com/yourusername/pgmigrate@v0.1.0
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The meaning is:

MAJOR.MINOR.PATCH
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

And users import it like this:

import "github.com/yourusername/pgmigrate/v2"
Enter fullscreen mode Exit fullscreen mode

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/*
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

or:

repository not found
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

Before Go workspaces, developers often used replace in go.mod:

replace github.com/mycompany/shared-lib => ../shared-lib
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For larger services, I also check:

go vet ./...
Enter fullscreen mode Exit fullscreen mode

And if vendoring is used:

go mod vendor
go test -mod=vendor ./...
Enter fullscreen mode Exit fullscreen mode

This small routine prevents many annoying problems in CI.

My practical checklist is:

  • commit both go.mod and go.sum
  • run go mod tidy before pushing
  • avoid unnecessary dependencies
  • do not expose internal application code as public packages
  • use GOPRIVATE for private company modules
  • tag releases properly when publishing packages
  • be careful with replace directives 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
Enter fullscreen mode Exit fullscreen mode

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)