Go is the language of cloud-native infrastructure. Kubernetes, Docker, Terraform, Prometheus, etcd, Consul, Hugo, the GitHub CLI—all written in Go. If you work in modern infrastructure, you read Go regularly whether you intend to or not. Learning to write it well is one of the highest-leverage investments an ops engineer or backend developer can make.
This isn’t a “Hello, World” tutorial. It covers the parts of Go that actually matter for writing reliable systems: the concurrency model, error handling patterns, standard library depth, and practical tooling for building CLIs and HTTP services.
Why Go for Systems Work
The case for Go in infrastructure is concrete:
Single self-contained binary. go build produces a statically linked binary with no runtime dependencies. Copy it to a server, container, or Lambda function—it just runs. No Python virtualenvs, no JVM, no shared libraries to manage.
Fast compilation. A large Go codebase builds in seconds. This matters for CI pipelines, developer iteration speed, and hot reloads in development.
Low memory footprint. A typical Go HTTP service uses 50–150 MB RAM. The equivalent Python service uses 300–600 MB. At scale, this is the difference between running 10 services on a node and running 40.
First-class concurrency. Goroutines cost ~2 KB of stack versus ~2 MB for OS threads. You can spawn hundreds of thousands of goroutines. This is why Go excels at I/O-intensive workloads: proxies, API gateways, concurrent scrapers, health checkers.
Cross-compilation built in. GOOS=linux GOARCH=amd64 go build produces a Linux binary from macOS or Windows. One command, no toolchains to install.
Standard library that covers 90% of needs. HTTP client/server, JSON, subprocess execution, structured logging, TLS, templating—all in the standard library without external dependencies.
The result is a language purpose-built for the kind of code ops engineers write: reliable, deployable anywhere, handling many concurrent operations.
Language Fundamentals
Types, Structs, and Interfaces
Go uses static typing with inference. The fundamentals:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
// Basic types
var count int = 10
name := "web01" // Short declaration with type inference
enabled := true
// Structs are the primary data structure
type Host struct {
Name string
IP string
Port int
Tags []string
}
// Methods on structs
func (h Host) Address() string {
return fmt.Sprintf("%s:%d", h.IP, h.Port)
}
// Pointer receiver — modifies the struct
func (h *Host) AddTag(tag string) {
h.Tags = append(h.Tags, tag)
}
// Create and use
host := Host{Name: "web01", IP: "10.0.1.10", Port: 22}
host.AddTag("prod")
fmt.Println(host.Address()) // 10.0.1.10:22
|
Interfaces are the mechanism for abstraction and testability in Go. An interface is a set of method signatures—any type that implements those methods satisfies the interface automatically (no implements keyword needed):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
// Define interface at the consumer, not the provider
type HealthChecker interface {
Check(ctx context.Context) error
}
// HTTP-based implementation
type HTTPHealthChecker struct {
URL string
Client *http.Client
}
func (h *HTTPHealthChecker) Check(ctx context.Context) error {
req, _ := http.NewRequestWithContext(ctx, "GET", h.URL, nil)
resp, err := h.Client.Do(req)
if err != nil {
return fmt.Errorf("check failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unhealthy: status %d", resp.StatusCode)
}
return nil
}
// Function accepts interface — testable with any implementation
func MonitorService(ctx context.Context, checker HealthChecker, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := checker.Check(ctx); err != nil {
slog.Error("health check failed", "error", err)
}
}
}
}
|
The golden rule: accept interfaces, return concrete types. This keeps code flexible at call sites and explicit at definition sites.
Slices and Maps
Slices are Go’s dynamic arrays—not the same as arrays. Pre-allocate when you know the size:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
// Slice with pre-allocated capacity (avoids repeated reallocation)
hosts := make([]Host, 0, len(inventory))
for _, item := range inventory {
hosts = append(hosts, parseHost(item))
}
// Slice operations
servers := []string{"web01", "web02", "web03"}
prod := servers[1:] // ["web02", "web03"] — shares underlying array
copy := append([]string{}, servers...) // Deep copy
// Iterate
for i, server := range servers {
fmt.Printf("%d: %s\n", i, server)
}
// Maps
hostByName := make(map[string]Host)
hostByName["web01"] = Host{Name: "web01", IP: "10.0.1.10"}
// Safe lookup — ok is false if key doesn't exist
if host, ok := hostByName["web01"]; ok {
fmt.Println(host.IP)
}
// Delete
delete(hostByName, "web01")
|
Error Handling
Go doesn’t have exceptions. Functions return errors as values—the caller decides what to do. This is intentional: it makes error handling explicit and visible in code.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// Return error as last value — idiomatic Go
func fetchConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", path, err)
}
var config Config
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("parse config %s: %w", path, err)
}
return &config, nil
}
// Caller handles error
config, err := fetchConfig("/etc/myapp/config.yaml")
if err != nil {
log.Fatalf("failed to load config: %v", err)
}
|
Error wrapping with %w preserves the original error while adding context. errors.Is and errors.As unwrap the chain:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
var ErrNotFound = errors.New("not found")
func getDeployment(id string) (*Deployment, error) {
d, ok := deployments[id]
if !ok {
return nil, fmt.Errorf("deployment %s: %w", id, ErrNotFound)
}
return d, nil
}
// Caller checks specific error type
d, err := getDeployment("deploy-xyz")
if err != nil {
if errors.Is(err, ErrNotFound) {
// Handle specifically
return createDeployment(id)
}
return err // Propagate unexpected errors
}
// errors.As for structured error types
type HTTPError struct {
Code int
Message string
}
func (e *HTTPError) Error() string { return fmt.Sprintf("HTTP %d: %s", e.Code, e.Message) }
var httpErr *HTTPError
if errors.As(err, &httpErr) {
if httpErr.Code == 429 {
// Rate limited — back off
}
}
|
defer
defer schedules a function call to run when the surrounding function returns, regardless of how it returns (normal, early return, or panic). Critical for cleanup:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
func processFile(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // Guaranteed to run when function exits
conn, err := db.Connect()
if err != nil {
return err
}
defer conn.Close() // Both closures guaranteed
// Process...
return nil
}
|
Multiple defers execute in LIFO order—last deferred runs first. Use this to pair resource acquisition with cleanup at the point of acquisition, not at every exit point.
Concurrency Model
Go’s concurrency is built on two primitives: goroutines and channels. The philosophy: communicate by sharing, don’t share memory to communicate.
Goroutines
A goroutine is a lightweight thread managed by the Go runtime. Starting one costs ~2 KB of stack. You can run hundreds of thousands concurrently:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// Start a goroutine with go keyword
go func() {
result := expensiveOperation()
process(result)
}()
// Goroutines are cheap — use them liberally for I/O
for _, host := range hosts {
host := host // Go 1.22+: loop variable is scoped per iteration automatically
go func() {
if err := checkHealth(host); err != nil {
slog.Error("health check failed", "host", host.Name, "error", err)
}
}()
}
|
Go 1.22 loop variable fix: Prior to 1.22, all goroutines spawned in a loop shared the same loop variable—a notorious source of bugs. Go 1.22 gives each iteration its own copy. Specify go 1.22 or later in go.mod to get this behavior.
Channels
Channels are typed conduits for communication between goroutines:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
// Unbuffered channel — sender blocks until receiver reads
results := make(chan string)
go func() {
result := doWork()
results <- result // Blocks until receiver is ready
}()
value := <-results // Blocks until sender sends
// Buffered channel — sender doesn't block until buffer is full
jobs := make(chan Job, 100)
// Range over channel until closed
go func() {
for job := range jobs {
process(job)
}
}()
for _, job := range allJobs {
jobs <- job
}
close(jobs) // Signal no more jobs — range loop exits
|
sync.WaitGroup
Wait for a set of goroutines to finish:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
func checkAllHosts(hosts []Host) map[string]error {
results := make(map[string]error, len(hosts))
var mu sync.Mutex
var wg sync.WaitGroup
for _, host := range hosts {
wg.Add(1)
go func(h Host) {
defer wg.Done()
err := checkHealth(h)
mu.Lock()
results[h.Name] = err
mu.Unlock()
}(host)
}
wg.Wait()
return results
}
|
context.Context
context.Context carries cancellation signals and deadlines through your call stack. It’s the mechanism for enforcing timeouts and cleaning up goroutines:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// Always defer cancel to release resources
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Pass ctx to every I/O operation
resp, err := http.NewRequestWithContext(ctx, "GET", url, nil)
// Check for cancellation
select {
case <-ctx.Done():
return ctx.Err() // context.DeadlineExceeded or context.Canceled
case result := <-workChan:
return result
}
|
Always accept context.Context as the first parameter of functions that do I/O. Always pass it through. This gives callers the ability to cancel work at any point in the call chain.
Worker Pool Pattern
When you have N tasks but don’t want to spawn N goroutines (e.g., limited API rate), use a worker pool:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
func deployWithPool(services []Service, concurrency int) []error {
type result struct {
name string
err error
}
jobs := make(chan Service, len(services))
results := make(chan result, len(services))
// Start fixed number of workers
var wg sync.WaitGroup
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for svc := range jobs {
err := deployService(svc)
results <- result{name: svc.Name, err: err}
}
}()
}
// Enqueue all jobs
for _, svc := range services {
jobs <- svc
}
close(jobs)
// Wait for workers then close results
go func() {
wg.Wait()
close(results)
}()
// Collect results
var errs []error
for r := range results {
if r.err != nil {
slog.Error("deploy failed", "service", r.name, "error", r.err)
errs = append(errs, r.err)
}
}
return errs
}
|
select Statement
select lets a goroutine wait on multiple channel operations simultaneously:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
func pollWithTimeout(ctx context.Context, pollFn func() (bool, error)) error {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return fmt.Errorf("timed out waiting: %w", ctx.Err())
case <-ticker.C:
done, err := pollFn()
if err != nil {
return err
}
if done {
return nil
}
slog.Info("still waiting...")
}
}
}
|
Standard Library Highlights
net/http
The standard library HTTP package handles both client and server work without external dependencies.
HTTP client:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
// Always use a custom client with timeouts—never http.DefaultClient in production
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, body)
}
var result MyResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
|
HTTP server with Go 1.22+ routing:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
mux := http.NewServeMux()
// Go 1.22+: method and path parameter support in ServeMux
mux.HandleFunc("GET /deployments", listDeployments)
mux.HandleFunc("POST /deployments", createDeployment)
mux.HandleFunc("GET /deployments/{id}", getDeployment)
mux.HandleFunc("DELETE /deployments/{id}", deleteDeployment)
// Extract path parameter
func getDeployment(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// ...
}
|
Middleware pattern:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"duration", time.Since(start),
)
})
}
func authMiddleware(token string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer "+token {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// Compose middleware
handler := loggingMiddleware(authMiddleware(apiToken, mux))
|
Graceful shutdown:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
server := &http.Server{
Addr: ":8080",
Handler: handler,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
// Start server in goroutine
go func() {
slog.Info("server starting", "addr", server.Addr)
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
slog.Error("server error", "error", err)
os.Exit(1)
}
}()
// Wait for termination signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
slog.Info("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
slog.Error("forced shutdown", "error", err)
}
|
log/slog (Go 1.21+)
log/slog is the standard library’s structured logging package. It replaces ad-hoc fmt.Printf logging with key-value structured output that works with log aggregation systems:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
import "log/slog"
// Default logger writes text to stderr
slog.Info("deployment started", "service", "api", "version", "1.2.3")
// Output: 2026/04/10 09:15:00 INFO deployment started service=api version=1.2.3
// JSON handler for production (parse-friendly for Datadog, Loki, etc.)
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
slog.Info("request processed",
"method", "GET",
"path", "/api/deployments",
"status", 200,
"duration_ms", 42,
)
// Output: {"time":"2026-04-10T09:15:00Z","level":"INFO","msg":"request processed","method":"GET","path":"/api/deployments","status":200,"duration_ms":42}
// Add consistent fields with a logger group
serviceLogger := slog.With("service", "api", "env", "prod")
serviceLogger.Error("database error", "error", err, "query", query)
|
os/exec
Execute system commands safely. Never concatenate user input into shell commands:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
// Safe: arguments are separate, no shell injection possible
cmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", manifestPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return fmt.Errorf("kubectl failed with exit code %d", exitErr.ExitCode())
}
return fmt.Errorf("kubectl exec: %w", err)
}
// Capture output
out, err := exec.CommandContext(ctx, "git", "rev-parse", "HEAD").Output()
if err != nil {
return "", err
}
commitHash := strings.TrimSpace(string(out))
|
encoding/json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
type Deployment struct {
ID string `json:"id"`
Service string `json:"service"`
Version string `json:"version"`
Created time.Time `json:"created_at"`
Internal string `json:"-"` // Never serialized
Optional string `json:"optional,omitempty"` // Omit if empty
}
// Marshal
d := Deployment{ID: "abc123", Service: "api", Version: "1.2.3"}
data, err := json.Marshal(d)
// Pretty print
data, err = json.MarshalIndent(d, "", " ")
// Unmarshal
var d2 Deployment
if err := json.Unmarshal(data, &d2); err != nil {
return fmt.Errorf("parse deployment: %w", err)
}
// Streaming (preferred for HTTP responses — no need to buffer entire body)
resp, _ := client.Do(req)
defer resp.Body.Close()
var result []Deployment
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
|
The standard toolkit for Go CLIs is Cobra (command structure and flags) with Viper (configuration). kubectl, Terraform, Hugo, and the GitHub CLI all use Cobra.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
|
// main.go
package main
import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func main() {
rootCmd := &cobra.Command{
Use: "deploy",
Short: "Deployment management tool",
}
// Persistent flags apply to all subcommands
rootCmd.PersistentFlags().StringP("api-url", "u", "", "API base URL")
rootCmd.PersistentFlags().StringP("token", "t", "", "API token")
viper.BindPFlags(rootCmd.PersistentFlags())
// Viper reads from config file, env vars, flags (in priority order)
viper.SetEnvPrefix("DEPLOY")
viper.AutomaticEnv() // DEPLOY_API_URL → api-url
viper.SetConfigName("deploy")
viper.AddConfigPath("$HOME/.config/deploy")
viper.ReadInConfig()
rootCmd.AddCommand(statusCmd(), deployCmd(), rollbackCmd())
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
func deployCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "service SERVICE",
Short: "Deploy a service",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
service := args[0]
version, _ := cmd.Flags().GetString("version")
dryRun, _ := cmd.Flags().GetBool("dry-run")
client := newAPIClient(
viper.GetString("api-url"),
viper.GetString("token"),
)
return runDeploy(cmd.Context(), client, service, version, dryRun)
},
}
cmd.Flags().StringP("version", "v", "", "Version to deploy (required)")
cmd.MarkFlagRequired("version")
cmd.Flags().Bool("dry-run", false, "Preview changes without applying")
return cmd
}
|
The RunE variant (vs Run) returns an error, which Cobra prints and exits with code 1. Use RunE in all commands so errors propagate cleanly.
Testing
Table-Driven Tests
The idiomatic Go testing pattern. Define test cases as a slice of structs, range over them with t.Run:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
func TestParseDeploymentID(t *testing.T) {
tests := []struct {
name string
input string
want string
wantErr bool
}{
{
name: "valid ID",
input: "deploy-abc123",
want: "abc123",
},
{
name: "empty input",
input: "",
wantErr: true,
},
{
name: "missing prefix",
input: "abc123",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseDeploymentID(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
}
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
|
t.Run creates a subtest for each case. Run a specific case with go test -run TestParseDeploymentID/empty_input.
Testing HTTP Handlers
The net/http/httptest package lets you test handlers without starting a real server:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
func TestGetDeploymentHandler(t *testing.T) {
// Create a handler backed by in-memory store
store := &InMemoryStore{
deployments: map[string]*Deployment{
"abc123": {ID: "abc123", Service: "api", Status: "ready"},
},
}
handler := NewDeploymentHandler(store)
// Record the response
req := httptest.NewRequest("GET", "/deployments/abc123", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
resp := w.Result()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var d Deployment
json.NewDecoder(resp.Body).Decode(&d)
if d.ID != "abc123" {
t.Errorf("unexpected deployment ID: %s", d.ID)
}
}
// httptest.NewServer for integration tests against real HTTP
func TestClientIntegration(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(Deployment{ID: "test-123"})
}))
defer server.Close()
client := NewAPIClient(server.URL, "test-token")
d, err := client.GetDeployment(context.Background(), "test-123")
if err != nil {
t.Fatal(err)
}
if d.ID != "test-123" {
t.Errorf("unexpected ID: %s", d.ID)
}
}
|
Benchmarks
1
2
3
4
5
6
7
8
9
10
|
func BenchmarkJSONMarshal(b *testing.B) {
d := Deployment{ID: "abc123", Service: "api", Version: "1.2.3"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
json.Marshal(d)
}
}
// Run: go test -bench=. -benchmem
// BenchmarkJSONMarshal-8 2847303 421 ns/op 128 B/op 2 allocs/op
|
go build / go run
1
2
3
4
5
6
7
8
9
10
11
|
# Build for current platform
go build -o bin/deploy ./cmd/deploy
# Cross-compile for Linux
GOOS=linux GOARCH=amd64 go build -o bin/deploy-linux ./cmd/deploy
# Inject version at build time
go build -ldflags="-X main.version=$(git describe --tags)" -o bin/deploy ./cmd/deploy
# Run without building
go run ./cmd/deploy status --env prod
|
go test
1
2
3
4
5
6
|
go test ./... # All packages
go test -v ./pkg/deploy/... # Verbose, specific package
go test -race ./... # Race condition detector (always run in CI)
go test -cover ./... # Coverage report
go test -bench=. -benchmem ./... # Benchmarks
go test -run TestDeploy/dry_run ./... # Specific subtest
|
Always run with -race in CI. The race detector catches a class of concurrency bugs that are nearly impossible to find manually.
golangci-lint
The standard linter suite for Go. Runs dozens of linters in parallel:
1
2
3
4
5
|
# Install
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
# Run
golangci-lint run ./...
|
A minimal .golangci.yml for ops tooling:
1
2
3
4
5
6
7
8
|
linters:
enable:
- errcheck # Unchecked errors
- govet # Suspicious constructs
- staticcheck # Advanced static analysis
- exhaustive # Switch exhaustiveness
- noctx # Missing context in HTTP calls
- gosec # Security issues
|
goreleaser
Automates cross-platform builds and GitHub releases:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
# .goreleaser.yml
builds:
- binary: deploy
main: ./cmd/deploy
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
ldflags:
- -s -w
- -X main.version={{.Version}}
archives:
- format: tar.gz
format_overrides:
- goos: windows
format: zip
checksum:
name_template: "checksums.txt"
release:
github:
owner: myorg
name: deploy
|
1
|
goreleaser release --clean
|
One command produces binaries for 6 platform/arch combinations, creates a GitHub release with checksums, and uploads everything.
Complete Example: Deployment Status CLI
A real CLI tool that queries a deployment API and renders a status table:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
|
// cmd/status/main.go
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
type Deployment struct {
Name string `json:"name"`
Version string `json:"version"`
Status string `json:"status"`
DesiredReplicas int `json:"desiredReplicas"`
ReadyReplicas int `json:"readyReplicas"`
}
func fetchDeployments(ctx context.Context, apiURL, token string) ([]Deployment, error) {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(ctx, "GET", apiURL+"/v1/deployments", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch deployments: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned %d", resp.StatusCode)
}
var result struct {
Items []Deployment `json:"items"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return result.Items, nil
}
func printTable(deployments []Deployment) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
fmt.Fprintln(w, "NAME\tVERSION\tSTATUS\tREADY")
fmt.Fprintln(w, "----\t-------\t------\t-----")
for _, d := range deployments {
ready := fmt.Sprintf("%d/%d", d.ReadyReplicas, d.DesiredReplicas)
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", d.Name, d.Version, d.Status, ready)
}
w.Flush()
}
func main() {
root := &cobra.Command{
Use: "depctl",
Short: "Deployment control CLI",
}
root.PersistentFlags().String("api-url", "", "API URL ($API_URL)")
root.PersistentFlags().String("token", "", "API token ($API_TOKEN)")
viper.BindPFlags(root.PersistentFlags())
viper.BindEnv("api-url", "API_URL")
viper.BindEnv("token", "API_TOKEN")
statusCmd := &cobra.Command{
Use: "status",
Short: "Show deployment statuses",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
deployments, err := fetchDeployments(
ctx,
viper.GetString("api-url"),
viper.GetString("token"),
)
if err != nil {
return err
}
printTable(deployments)
return nil
},
}
root.AddCommand(statusCmd)
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
|
Build it: go build -o depctl ./cmd/status. Copy the binary anywhere. Run it: API_URL=https://api.example.com API_TOKEN=xxx depctl status.
Go vs Python vs Rust: Decision Framework
|
Go |
Python |
Rust |
| Best for |
APIs, CLIs, infrastructure tools |
Automation scripts, ML/data |
Systems code, kernel, performance-critical |
| Memory use |
~100–300 MB |
~300–600 MB |
~50–100 MB |
| Concurrency |
Goroutines (easy) |
GIL-limited (asyncio helps) |
Ownership-safe (complex) |
| Deployment |
Single binary |
Needs runtime |
Single binary |
| Learning curve |
Medium |
Low |
High |
| Startup time |
Milliseconds |
100ms–1s |
Milliseconds |
| Ecosystem |
Cloud-native tools |
ML/AI/scripting |
Systems/embedded |
When to choose Go:
- Building a CLI tool others will install
- Writing an HTTP service or API proxy
- Any workload needing high concurrency (many simultaneous connections)
- Replacing a bash script that’s gotten too complex
- When you need a single binary with no runtime dependencies
When to stick with Python:
- Short scripts you’ll run once or twice
- ML/data work where the libraries don’t exist in Go
- Rapid prototyping where development speed matters more than the artifact
When to consider Rust:
- Performance is the primary constraint
- Memory safety is non-negotiable (e.g., parsing untrusted input)
- You’re writing something that will run on resource-constrained hardware
For the majority of DevOps tooling and backend services, Go hits the sweet spot: fast enough, safe enough, and productive enough to ship quickly and maintain confidently.
Getting Started
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
# Install Go (use official installer or mise)
mise use go@latest
# Or: https://go.dev/dl/
# New module
mkdir myapp && cd myapp
go mod init github.com/myorg/myapp
# Add dependencies
go get github.com/spf13/cobra
go get github.com/spf13/viper
# Build
go build ./...
# Test with race detector
go test -race ./...
# Lint
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
golangci-lint run
|
The official Go Tour covers syntax in a few hours. The Standard Library documentation is excellent and usually sufficient before reaching for third-party packages. Read Effective Go once—it explains the idiomatic patterns that make Go code readable to other Go developers.
Comments