The go command explained: run, build, test, mod, fmt and vet

A practical guide to the essential Go commands: go run, build, test, fmt, vet and mod. Everything you need to work locally and in CI.

Cover for The go command explained: run, build, test, mod, fmt and vet

When you start with Go coming from Java, Python or Node, one of the first things you notice is that you don’t need Maven, Gradle, pytest, black or ESLint. The go binary that ships with the language already includes a compiler, dependency manager, test runner, formatter, linter and code generator. All in a single command.

This is not an accident. It is a deliberate design decision. The Go team bet from the beginning on standard tools integrated into the toolchain itself. Less configuration, fewer debates about which tool to use, less friction when onboarding someone new to the project. If you know Go, you already know how to compile, test and format any Go project.

This article covers the subcommands you will use every day: go run, go build, go test, go fmt, go vet, go mod, go generate, go install and go env. With practical examples, useful flags and how to combine them in a CI pipeline.


go run: compile and execute in one step

go run compiles and executes a Go program without generating a persistent binary. It is the equivalent of python script.py or node app.js: useful for rapid development and scripts.

go run main.go

If your main imports other files from the same package, you can pass multiple files or use the directory pattern:

go run .
go run ./cmd/server

Passing arguments to the program

Arguments after the file or package are passed directly to the program:

go run main.go --port 8080 --env production

Useful go run flags

# Show the commands it runs internally
go run -x main.go

# Compile without optimizations (useful for debugging with Delve)
go run -gcflags="-N -l" main.go

When NOT to use go run

go run compiles every time you execute it. There is no implicit cache of the resulting binary. For a server you restart 50 times a day, that is acceptable. For a binary you are going to distribute or deploy, you need go build.

go run is for local development. Never use it in production or in a final Dockerfile.


go build: creating binaries

go build compiles your code and generates an executable binary. If you do not specify an output name, it uses the module or directory name.

go build -o server ./cmd/server

The resulting binary is static by default (in most cases): it needs no runtime, it does not require Go to be installed on the target machine. Copy the binary and it works.

Cross-compilation

One of the best features of Go is cross-compilation. You can generate binaries for any platform from your machine:

# Linux AMD64 (the typical choice for servers and Docker containers)
GOOS=linux GOARCH=amd64 go build -o server-linux ./cmd/server

# macOS ARM (Apple Silicon)
GOOS=darwin GOARCH=arm64 go build -o server-mac ./cmd/server

# Windows
GOOS=windows GOARCH=amd64 go build -o server.exe ./cmd/server

You don’t need a virtual machine, you don’t need Docker, you don’t need a special CI. Just two environment variables. This is something that in Java or Python simply does not exist natively.

Common build flags

# Reduce binary size by removing debug information
go build -ldflags="-s -w" -o server ./cmd/server

# Inject variables at compile time (version, commit, date)
go build -ldflags="-X main.version=1.2.3 -X main.commit=$(git rev-parse HEAD)" -o server ./cmd/server

# Fully static build (no CGO dependencies)
CGO_ENABLED=0 go build -o server ./cmd/server

# See what commands it runs internally
go build -x -o server ./cmd/server

Injecting version at compile time

A very common pattern is to define variables in your main.go and fill them with -ldflags:

package main

import "fmt"

var (
    version = "dev"
    commit  = "none"
    date    = "unknown"
)

func main() {
    fmt.Printf("server %s (commit: %s, built: %s)\n", version, commit, date)
}
go build -ldflags="-X main.version=1.2.3 -X main.commit=$(git rev-parse --short HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" -o server .

This gives you a binary that knows exactly which version it is and when it was compiled. Very useful for logs and for diagnosing issues in production.


go test: tests, coverage and benchmarks

go test is the built-in test runner. It reads *_test.go files, executes functions that start with Test, Benchmark or Example, and reports results. No JUnit, no pytest, no configuration.

# Run tests in the current package
go test

# Run tests across the entire project
go test ./...

# With verbose output
go test -v ./...

# Run only tests matching a pattern
go test -run TestCreateUser ./internal/user/

If you want to go deeper into testing, I have a dedicated article on testing in Go with more detail on table-driven tests, mocks and organization.

Coverage

# See coverage percentage
go test -cover ./...

# Generate a coverage file for detailed inspection
go test -coverprofile=coverage.out ./...

# View coverage line by line in the browser
go tool cover -html=coverage.out

# View coverage per function
go tool cover -func=coverage.out

The output of -cover gives you a percentage per package:

ok  	github.com/user/project/internal/user	0.012s	coverage: 87.3% of statements
ok  	github.com/user/project/internal/auth	0.008s	coverage: 92.1% of statements

Benchmarks

Go has native support for benchmarks. You define Benchmark* functions in your test files:

func BenchmarkParseConfig(b *testing.B) {
    data := loadTestConfig()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        ParseConfig(data)
    }
}

And you run them with:

# Run benchmarks
go test -bench=. ./...

# Benchmarks with memory information
go test -bench=. -benchmem ./...

# Only benchmarks, no regular tests
go test -bench=. -run=^$ ./...

# Run a specific benchmark
go test -bench=BenchmarkParseConfig ./internal/config/

Typical output:

BenchmarkParseConfig-8    1000000    1052 ns/op    256 B/op    4 allocs/op

That tells you: it ran one million times, each run took 1052 nanoseconds, allocated 256 bytes and performed 4 allocations. Concrete information for optimization.

Race detector

Go has a built-in race condition detector. Enable it with -race:

go test -race ./...

This instruments the code to detect concurrent accesses to shared memory. It is slower, so do not use it in benchmarks, but it should be mandatory in CI.

Useful go test flags

# Global timeout (default 10 minutes)
go test -timeout 30s ./...

# Run tests in parallel (default GOMAXPROCS)
go test -parallel 4 ./...

# Disable test cache (forces re-execution)
go test -count=1 ./...

# Short tests (skip heavy tests marked with testing.Short())
go test -short ./...

go fmt: the end of formatting debates

go fmt formats your code according to the official Go style. There is no configuration, no options, no .editorconfig or .prettierrc. One format. For everyone.

# Format a file
go fmt main.go

# Format the entire project
go fmt ./...

In practice, most projects use gofmt (the underlying formatter) or goimports (which also organizes imports):

# goimports: formats + organizes imports + adds missing imports
goimports -w .

Why this matters

In Java you have Checkstyle, SpotBugs, Google Java Format, IntelliJ formatter, each with its own config. In Python you have Black, YAPF, autopep8, isort. In JavaScript you have Prettier, ESLint, Standard. Each project picks one, configures it, and there is always someone whose IDE is set up differently and introduces formatting changes in diffs.

In Go that problem does not exist. go fmt is the standard. Period. No debate over tabs vs spaces (tabs), no debate over where the opening brace goes (same line), no debate over maximum line width (there is no forced limit). The format is part of the language.

If your code is not formatted with go fmt, the community considers it wrong. It is that simple.


go vet: built-in static analysis

go vet examines your code looking for common mistakes that the compiler does not detect: incorrectly passed arguments to fmt.Printf, loop variables captured in goroutines, impossible conditions, unreachable code and more.

# Analyze the current package
go vet

# Analyze the entire project
go vet ./...

What go vet detects

Some examples of what it finds:

// Printf with incorrect arguments
fmt.Printf("user: %d", username) // vet: wrong type for %d

// Copying a sync.Mutex (serious concurrency error)
var mu sync.Mutex
mu2 := mu // vet: assignment copies lock value

// Impossible comparison
if x != x { // vet: suspicious comparison
}

// Unreachable code
func foo() int {
    return 1
    fmt.Println("never") // vet: unreachable code
}

go vet is not a full linter like golangci-lint, but it covers the most dangerous errors and is fast. In CI it should always run.

go vet vs golangci-lint

go vet is a subset. golangci-lint bundles dozens of linters (including vet) and allows you to configure rules. For a serious project, use both:

# In CI: start with the fast, standard check
go vet ./...

# Then the full analysis
golangci-lint run

go mod: dependency management

go mod is Go’s module system. It manages your project’s dependencies through the go.mod file. If you come from other languages: go.mod is your pom.xml, package.json or requirements.txt.

For a complete guide on modules, see the Go modules article. Here I cover the subcommands you will use every day.

go mod init

Initializes a new module:

go mod init github.com/user/project

This creates a go.mod with the module name and Go version:

module github.com/user/project

go 1.22

go mod tidy

The command you will use the most. It analyzes your imports, adds missing dependencies to go.mod, and removes unused ones:

go mod tidy

Run it after adding or removing imports. In CI, a common technique is to verify that go.mod and go.sum are up to date:

go mod tidy
git diff --exit-code go.mod go.sum

If there are differences, someone forgot to run go mod tidy before pushing.

go mod download

Downloads all dependencies to the local cache without compiling anything:

go mod download

Useful in Dockerfiles to leverage layer caching:

FROM golang:1.22-alpine AS builder
WORKDIR /app

# First copy only go.mod and go.sum
COPY go.mod go.sum ./
RUN go mod download

# Then the code (this layer is invalidated more often)
COPY . .
RUN go build -o server ./cmd/server

go mod vendor

Copies all dependencies into a vendor/ directory inside the project:

go mod vendor

This allows builds without internet access and guarantees reproducibility. To compile using the vendor:

go build -mod=vendor -o server ./cmd/server

go mod graph and go mod why

For debugging dependencies:

# View the full dependency graph
go mod graph

# Find out why a dependency is in your go.mod
go mod why github.com/lib/pq

go mod why is especially useful when you see a dependency in go.sum and don’t know who brought it in.


go generate: code generation

go generate runs commands defined in special comments inside your Go code. It is not a build system or a preprocessor: it is a mechanism to run tools that generate Go code.

//go:generate stringer -type=Status
//go:generate mockgen -source=repository.go -destination=mock_repository.go
//go:generate protoc --go_out=. --go-grpc_out=. api.proto

To run all generators in the project:

go generate ./...

Common use cases

  • Enums with stringer: Generates String() methods for types based on iota.
  • Mocks with mockgen: Generates mock implementations of interfaces for tests.
  • Protocol Buffers: Generates Go code from .proto files.
  • SQL embeds: Tools like sqlc generate type-safe Go code from SQL queries.

Best practices with go generate

  1. Commit the generated code. Whoever clones your repo should not need to have protoc, mockgen or stringer installed to compile.
  2. Verify in CI that the generated code is up to date:
go generate ./...
git diff --exit-code

If there are differences, someone modified the source code without regenerating.


go install: installing tools

go install compiles and installs a binary to $GOPATH/bin (or $GOBIN if you have it set). It is the standard way to install tools written in Go.

# Install a specific tool with a version
go install golang.org/x/tools/cmd/goimports@latest
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.59.0

# Install from the current project
go install ./cmd/server

Difference between go install and go build

  • go build generates the binary in the current directory (or wherever you specify with -o).
  • go install generates the binary in $GOPATH/bin.

For CLI tools you want available globally, use go install. For your project, use go build.

Managing tool versions in the project

A common pattern is to have a tools.go file with a build tag that is never compiled, only so that go mod tidy records the tool dependencies:

//go:build tools

package tools

import (
    _ "github.com/golangci/golangci-lint/cmd/golangci-lint"
    _ "go.uber.org/mock/mockgen"
    _ "golang.org/x/tools/cmd/goimports"
)

This way the versions are pinned in go.mod and the whole team uses the same ones.


go env: understanding your environment

go env shows all the environment variables that affect the Go toolchain. It is the first command to run when something is not working as expected.

# View all variables
go env

# View a specific variable
go env GOPATH
go env GOROOT
go env GOOS
go env GOARCH

# View in JSON format
go env -json

Important variables

VariableWhat it does
GOPATHBase directory for dependencies and installed binaries
GOROOTGo installation directory
GOBINWhere binaries are installed with go install
GOOSTarget operating system for compilation
GOARCHTarget architecture for compilation
GOPROXYProxy for downloading modules (default https://proxy.golang.org)
GONOSUMCHECKModules that are not verified in the sumdb
CGO_ENABLEDWhether compiling C code is allowed (0 or 1)
GOFLAGSFlags applied to all go commands

Modifying variables persistently

# Change the proxy (useful in corporate environments)
go env -w GOPROXY=https://goproxy.io,direct

# Disable CGO by default
go env -w CGO_ENABLED=0

These settings are saved in $GOPATH/env and persist between sessions.


Combining commands in CI/CD

A CI pipeline for a typical Go project looks like this:

#!/bin/bash
set -euo pipefail

echo "=== Checking formatting ==="
gofmt -l . | tee /tmp/fmt-check
if [ -s /tmp/fmt-check ]; then
    echo "ERROR: unformatted files"
    exit 1
fi

echo "=== Static analysis ==="
go vet ./...

echo "=== Checking go.mod ==="
go mod tidy
git diff --exit-code go.mod go.sum

echo "=== Checking generated code ==="
go generate ./...
git diff --exit-code

echo "=== Tests with race detector ==="
go test -race -cover -coverprofile=coverage.out ./...

echo "=== Coverage ==="
go tool cover -func=coverage.out

echo "=== Build ==="
CGO_ENABLED=0 go build -ldflags="-s -w" -o /tmp/app ./cmd/server

Example with GitHub Actions

name: CI
on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'

      - name: Verify formatting
        run: |
          if [ -n "$(gofmt -l .)" ]; then
            echo "Code not formatted:"
            gofmt -l .
            exit 1
          fi

      - name: Vet
        run: go vet ./...

      - name: Test
        run: go test -race -coverprofile=coverage.out ./...

      - name: Build
        run: CGO_ENABLED=0 go build -ldflags="-s -w" -o app ./cmd/server

Notice the order: formatting first (it is instant and fails fast), then vet, then tests, then build. No Maven taking 30 seconds to start up, no npm install downloading 200 MB. A medium-sized Go project passes CI in under a minute.


Comparison table: Go tooling vs other ecosystems

TaskGoJavaPythonNode.js
Compilego buildmvn package / gradle buildN/A (interpreted)N/A (interpreted)
Rungo runjava -jar / mvn exec:javapython script.pynode app.js
Testsgo testJUnit + Maven/Gradlepytest / unittestJest / Vitest
Formatgo fmtgoogle-java-format / SpotlessBlack / YAPFPrettier
Lintergo vet + golangci-lintCheckstyle / SpotBugsRuff / Flake8 / PylintESLint
Dependenciesgo modMaven / Gradlepip / Poetry / uvnpm / pnpm / yarn
Coveragego test -coverJaCoCocoverage.py / pytest-covc8 / istanbul
Benchmarksgo test -benchJMHpytest-benchmarkBenchmark.js
Cross-compileGOOS=x GOARCH=y go buildGraalVM native-image (limited)Not nativeNot native (pkg)
Code generationgo generateAnnotation processorsNot standardNot standard

The main difference: in Go everything is a single binary with a consistent interface. In other ecosystems you need to install, configure and maintain separate tools for each task.


What makes Go’s tooling different

The go command is not just a compiler. It is a statement about how a language’s tooling should work. Formatting without configuration. Tests without an external framework. Cross-compilation with two environment variables. Dependency management without a separate lock file (the go.sum is auto-generated).

There are things it does not cover: advanced linting (you need golangci-lint), hot reload (you need air or similar), and release management (you need goreleaser or scripts). But the foundation the standard toolchain offers is more complete than that of any other language I have used.

If you are getting started with Go, spend an hour exploring go help and the subcommands we have covered. That hour will save you days of tool configuration that in other ecosystems you take for granted as necessary. If you want a guide to take your first steps, start with getting started with Go.

OshyTech

Backend and data engineering focused on scalable systems, automation, and AI.

Navigation

Copyright 2026 OshyTech. All Rights Reserved