Modern Go Development Workflow

Overview

Modern Go development requires more than just writing code. This chapter covers essential tools and practices for professional Go development including hot reload with air, goroutine leak detection with goleak, build tags, build info embedding, and IDE integration. These tools significantly improve development velocity and code quality.

Learning Objectives

By the end of this module, you will be able to:

  • Set up hot reload for rapid development iteration
  • Detect and prevent goroutine leaks
  • Use build tags to control compilation
  • Embed build information in Go 1.18+
  • Configure VSCode for Go development
  • Bootstrap new projects with Go Blueprint

Hot Reload With Air

What Is Air?

Air is a live-reloading tool for Go applications. It watches your files and automatically rebuilds and restarts your application when changes are detected.

Benefits

  • Faster development iteration
  • No manual restart needed
  • Configurable watch patterns
  • Works with Go modules
  • Colored output and error reporting

Installing Air

# Install air globally
$ go install github.com/cosmtrek/air@latest

# Verify installation
$ air -v

Air Configuration

Create a .air.toml configuration file in your project root:

root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"

[build]
  args_bin = []
  bin = "./tmp/main"
  cmd = "go build -o ./tmp/main ."
  delay = 1000
  exclude_dir = ["assets", "tmp", "vendor", "testdata"]
  exclude_file = []
  exclude_regex = ["_test.go"]
  exclude_unchanged = false
  follow_symlink = false
  full_bin = ""
  include_dir = []
  include_ext = ["go", "tpl", "tmpl", "html"]
  include_file = []
  kill_delay = "0s"
  log = "build-errors.log"
  poll = false
  poll_interval = 0
  rerun = false
  rerun_delay = 500
  send_interrupt = false
  stop_on_error = false

[color]
  app = ""
  build = "yellow"
  main = "magenta"
  runner = "green"
  watcher = "cyan"

[log]
  main_only = false
  time = false

[misc]
  clean_on_exit = false

[screen]
  clear_on_rebuild = false
  keep_scroll = true

Using Air

Initialize air in your project:

# Generate default .air.toml configuration
$ air init

# Start air (uses .air.toml)
$ air

# Use custom config file
$ air -c .air.custom.toml

When air is running:

  • Save any Go file → automatic rebuild and restart
  • Build errors show in terminal with colors
  • Application output streams to terminal

Air Best Practices

Exclude Test Files

[build]
  exclude_regex = ["_test.go"]

Custom Build Command

For projects with specific build requirements:

[build]
  cmd = "go build -tags dev -o ./tmp/main ./cmd/server"

Environment Variables

[build]
  full_bin = "APP_ENV=dev APP_USER=air ./tmp/main"

Goroutine Leak Detection With Goleak

What Is Goleak?

goleak is a goroutine leak detector developed by Uber. It verifies that all goroutines have been cleaned up when a test completes.

Why Detect Goroutine Leaks?

  • Prevent memory leaks
  • Find resource cleanup issues
  • Ensure proper shutdown
  • Improve application stability

Installing Goleak

$ go install go.uber.org/goleak@latest

Add to your project:

$ go get go.uber.org/goleak

Using Goleak In Tests

Basic Usage

package main

import (
    "testing"
    "go.uber.org/goleak"
)

func TestMain(m *testing.M) {
    os.Exit(goleak.VerifyTestMain(m))
}

func TestMyFunction(t *testing.T) {
    defer goleak.VerifyNone(t)

    // Your test code that might leak goroutines
    go func() {
        // This will be detected as a leak!
    }()
}

Goleak Options

Ignoring Expected Goroutines

Some goroutines are expected to continue running:

func TestWithIgnoredGoroutines(t *testing.T) {
    defer goleak.VerifyNone(t,
        goleak.IgnoreTopFunction("internal/poll.runtime_pollWait"),
        goleak.IgnoreCurrent(),
    )

    // Test code here
}

Package-Level Verification

func TestMain(m *testing.M) {
    goleak.VerifyTestMain(m,
        goleak.IgnoreTopFunction("github.com/example/pkg.background"),
    )
}

Common Goroutine Leak Patterns

Leaked Channel Reader

// BAD: Goroutine leaks if channel never receives
func processAsync(data string) {
    ch := make(chan string)
    go func() {
        result := <-ch  // Blocks forever if never sent
        process(result)
    }()
}

// GOOD: Use context for cancellation
func processAsyncWithContext(ctx context.Context, data string) {
    ch := make(chan string)
    go func() {
        select {
        case result := <-ch:
            process(result)
        case <-ctx.Done():
            return
        }
    }()
}

Goleak Best Practices

  1. Use TestMain for package-level verification
  2. Ignore known goroutines from dependencies
  3. Test cleanup logic explicitly
  4. Use contexts for goroutine cancellation
  5. Clean up resources in defer statements

Go 1.26 Goroutineleak Profile

What Is Goroutineleak Profile?

Go 1.26 introduces a built-in leak detection feature that works in production, not just tests.

Key Differences from goleak

  • Built into the runtime (no external dependencies)
  • Uses GC to detect unreachable synchronization primitives
  • Shows (leaked) annotation in profile output
  • Works via pprof like other profiles

When to Use

  • Production monitoring of long-running services
  • Debugging intermittent leaks that don’t appear in tests
  • When goleak isn’t sufficient (detects different leak types)

Enabling Goroutineleak Profile

The feature is currently experimental and requires a build flag:

# Build with goroutineleak support
$ GOEXPERIMENT=goroutineleakprofile go build

# Run tests with goroutineleak support
$ GOEXPERIMENT=goroutineleakprofile go test ./...

Note: The GOEXPERIMENT flag is a Go 1.26 requirement. The profile is expected to be generally available in Go 1.27 (expected August 2026), and this section will be simplified once that ships.

Using Goroutineleak In Tests


func TestGoroutineLeakDetection(t *testing.T) {
	prof := pprof.Lookup("goroutineleak")
	if prof == nil {
		t.Skip("goroutineleak profile not available - build with GOEXPERIMENT=goroutineleakprofile")
	}

	// Create a leak - goroutine blocked on unbuffered channel
	ch := make(chan int)
	go func() {
		ch <- 1 // Will block forever - no receiver
	}()

	// Force GC to identify leaked goroutines
	runtime.GC()
	time.Sleep(100 * time.Millisecond)

	var buf bytes.Buffer
	prof.WriteTo(&buf, 2) // 2 = full stack traces

	if strings.Contains(buf.String(), "(leaked)") {
		t.Log("Leak detected (expected):")
		t.Log(buf.String())
	}
}

Production Usage

Add the pprof HTTP handler to expose leak detection:

import (
    "net/http"
    _ "net/http/pprof"
)

func main() {
    go func() {
        http.ListenAndServe(":6060", nil)
    }()
    // Your application code
}

Query the endpoint:

$ curl http://localhost:6060/debug/pprof/goroutineleak?debug=2

Understanding The Output

goroutine 42 [chan send (leaked)]:
main.processWorkItems.func1()
    /app/worker.go:25 +0x45
created by main.processWorkItems
    /app/worker.go:20 +0x123

The (leaked) annotation indicates:

  • The goroutine is blocked on an unreachable primitive
  • No other goroutine can unblock it
  • It will stay blocked forever

Debug Levels

  • debug=0 - Protobuf format (for pprof tool)
  • debug=1 - Compact text format
  • debug=2 - Full stack traces (most useful for debugging)

Goleak Vs Goroutineleak Profile

Feature goleak goroutineleak
When Test time Any time
How Goroutine count GC reachability
External dep Yes (Uber) No (built-in)
False positives Possible Very rare
Production use No Yes
Go version Any 1.26+

Recommendation: Use both! goleak for tests, goroutineleak for production monitoring.

Build Tags

What Are Build Tags?

Build tags (also called build constraints) control which files are included in a package during compilation.

Use Cases

  • Platform-specific code
  • Feature flags
  • Development vs production builds
  • Integration vs unit tests
  • Conditional compilation

Build Tag Syntax

Go 1.17+ Syntax (Preferred)

At the top of your Go file:

//go:build linux && amd64
// +build linux amd64

package main

Legacy Syntax

// +build linux,amd64

package main

Note: Go 1.17+ requires both forms for backward compatibility.

Build Tag Examples

Platform-Specific Code

//go:build linux
// +build linux

package platform

func GetOS() string {
    return "Linux"
}
//go:build windows
// +build windows

package platform

func GetOS() string {
    return "Windows"
}

Feature Flags With Build Tags

Development Features

//go:build dev
// +build dev

package main

import "log"

func init() {
    log.SetFlags(log.Lshortfile | log.Ltime | log.Lmicroseconds)
    log.Println("Running in development mode")
}

Build with the tag:

$ go build -tags dev
$ go run -tags dev main.go

Multiple Build Tags

AND Logic

//go:build linux && amd64
// +build linux amd64

// Requires BOTH linux AND amd64

OR Logic

//go:build linux || darwin
// +build linux darwin

// Requires EITHER linux OR darwin

NOT Logic

//go:build !windows
// +build !windows

// Any platform EXCEPT windows

Build Tags In Testing

Integration Tests

//go:build integration
// +build integration

package main

import "testing"

func TestDatabaseIntegration(t *testing.T) {
    // Integration test that requires database
}

Run integration tests:

# Skip integration tests (default)
$ go test ./...

# Run integration tests
$ go test -tags integration ./...

Build Tags Best Practices

  1. Use new syntax (//go:build) for Go 1.17+
  2. Keep both forms for compatibility
  3. Document required tags in README
  4. Use consistent naming (dev, prod, integration)
  5. Separate test files with build tags
  6. CI/CD integration - run different tag sets in different jobs

Go 1.18+ Build Info Embedding

Build Info Embedding

Go 1.18+ automatically embeds build information that can be accessed at runtime.

What Information is Available?

  • Go version
  • Module path and version
  • VCS (Git) information
  • Commit hash
  • Dirty working tree status
  • Build time
  • Build settings

Accessing Build Info

package main

import (
    "fmt"
    "runtime/debug"
)

func main() {
    info, ok := debug.ReadBuildInfo()
    if !ok {
        fmt.Println("Build info not available")
        return
    }

    fmt.Println("Go Version:", info.GoVersion)
    fmt.Println("Module Path:", info.Path)
    fmt.Println("Main Module:", info.Main.Path, info.Main.Version)

    for _, setting := range info.Settings {
        fmt.Printf("%s = %s\n", setting.Key, setting.Value)
    }
}

VCS Information

When building from a Git repository:

package main

import (
    "fmt"
    "runtime/debug"
)

func printVCSInfo() {
    info, _ := debug.ReadBuildInfo()

    for _, setting := range info.Settings {
        switch setting.Key {
        case "vcs":
            fmt.Println("VCS:", setting.Value)  // git
        case "vcs.revision":
            fmt.Println("Commit:", setting.Value)  // commit hash
        case "vcs.time":
            fmt.Println("Commit Time:", setting.Value)
        case "vcs.modified":
            fmt.Println("Modified:", setting.Value)  // true/false
        }
    }
}

Custom Build Info With Ldflags

You can inject custom values at build time:

package main

import "fmt"

var (
    version   = "dev"
    buildTime = "unknown"
    gitCommit = "unknown"
)

func main() {
    fmt.Printf("Version: %s\n", version)
    fmt.Printf("Build Time: %s\n", buildTime)
    fmt.Printf("Git Commit: %s\n", gitCommit)
}

Build with custom values:

$ go build -ldflags "\
  -X main.version=1.2.3 \
  -X main.buildTime=$(date -u '+%Y-%m-%d_%H:%M:%S') \
  -X main.gitCommit=$(git rev-parse HEAD)" \
  -o myapp

Makefile For Build Info

VERSION := $(shell git describe --tags --always --dirty)
BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
GIT_COMMIT := $(shell git rev-parse HEAD)
GIT_BRANCH := $(shell git rev-parse --abbrev-ref HEAD)

LDFLAGS := -ldflags "\
	-X main.version=$(VERSION) \
	-X main.buildTime=$(BUILD_TIME) \
	-X main.gitCommit=$(GIT_COMMIT) \
	-X main.gitBranch=$(GIT_BRANCH)"

build:
	go build $(LDFLAGS) -o bin/myapp ./cmd/myapp

.PHONY: build

Version Command Pattern

Common pattern for CLI applications:

package main

import (
    "flag"
    "fmt"
    "runtime/debug"
)

var (
    version   = "dev"
    gitCommit = "unknown"
)

func main() {
    showVersion := flag.Bool("version", false, "Show version information")
    flag.Parse()

    if *showVersion {
        printVersion()
        return
    }

    // Application logic...
}

func printVersion() {
    fmt.Printf("Version: %s\n", version)
    fmt.Printf("Git Commit: %s\n", gitCommit)

    if info, ok := debug.ReadBuildInfo(); ok {
        fmt.Printf("Go Version: %s\n", info.GoVersion)
    }
}

VSCode Go Extension

VSCode Go Extension

The official Go extension for VSCode provides comprehensive Go development support.

Features

  • IntelliSense (code completion)
  • Code navigation (go to definition, find references)
  • Code formatting (gofmt, goimports)
  • Linting (revive, staticcheck)
  • Debugging
  • Testing support
  • Refactoring tools

Installing VSCode Go Extension

  1. Open VSCode
  2. Press Ctrl+Shift+X (or Cmd+Shift+X on Mac)
  3. Search for “Go”
  4. Install the official “Go” extension by Go Team at Google

Or install from command line:

$ code --install-extension golang.Go

Initial Setup

When you first open a Go file, VSCode will prompt to install Go tools. Or manually:

  1. Open Command Palette (Ctrl+Shift+P or Cmd+Shift+P)
  2. Type: Go: Install/Update Tools
  3. Select all tools and click OK

Tools installed:

  • gopls (language server)
  • dlv (debugger)
  • staticcheck (linter)
  • And more…

VSCode Settings For Go

Create or update .vscode/settings.json:

{
  "go.useLanguageServer": true,
  "go.lintTool": "revive",
  "go.lintOnSave": "workspace",
  "go.formatTool": "goimports",
  "[go]": {
    "editor.formatOnSave": true,
    "editor.codeActionsOnSave": {
      "source.organizeImports": true
    }
  },
  "go.testFlags": ["-v", "-race"],
  "go.coverOnSave": false,
  "go.coverOnSingleTest": true,
  "go.testTimeout": "30s"
}

Testing In VSCode

Run Tests

  • Click “run test” or “debug test” above test functions
  • Use Command Palette: Go: Test Function At Cursor
  • Keyboard: Ctrl+F5 (or Cmd+F5)

Test Output

  • View test results in terminal
  • See coverage highlights in editor
  • Click to navigate to failing tests

Test Explorer

  • View all tests in sidebar
  • Run/debug individual tests
  • See test status indicators

Debugging In VSCode

Quick Debug

  1. Set breakpoint (click left of line number)
  2. Press F5 or click “Debug” above main function
  3. Use debug toolbar to step through code

Debug Configuration

Create .vscode/launch.json:

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Launch Package",
      "type": "go",
      "request": "launch",
      "mode": "debug",
      "program": "${workspaceFolder}/cmd/myapp"
    },
    {
      "name": "Attach to Process",
      "type": "go",
      "request": "attach",
      "mode": "local",
      "processId": "${command:pickProcess}"
    }
  ]
}

Code Navigation

Go to Definition

  • F12 or Ctrl+Click on symbol
  • See definition in peek window or navigate to file

Find All References

  • Shift+F12 on symbol
  • View all usages in peek window

Outline View

  • Ctrl+Shift+O - Quick navigation to functions/types in file
  • Sidebar “Outline” section shows file structure

Go to Symbol in Workspace

  • Ctrl+T - Search for any symbol in workspace

Refactoring Tools

Extract Function

  1. Select code
  2. Right-click → “Refactor…”
  3. Choose “Extract to function”

Rename Symbol

  • F2 on symbol
  • Renames across entire workspace
  • Updates all references

Generate Interface Stubs

Command Palette → Go: Generate Interface Stubs

VSCode Go Best Practices

  1. Enable format on save - Consistent code style
  2. Use gopls - Modern language server
  3. Configure linter - Catch issues early
  4. Learn keyboard shortcuts - Faster development
  5. Use test explorer - Better test management
  6. Set up debugging - Debug efficiently
  7. Install recommended extensions - Error Lens, GitLens, etc.

Go Blueprint

What Is Go Blueprint?

Go Blueprint is a CLI tool that helps you bootstrap Go projects with modern best practices and structure.

Features

  • Interactive project setup
  • Multiple frameworks (Gin, Fiber, Chi, Echo, HttpRouter, Gorilla/Mux, Standard Library)
  • Database integration (PostgreSQL, MySQL, SQLite, MongoDB)
  • Containerization (Docker, Docker Compose)
  • CI/CD templates (GitHub Actions)
  • Testing setup
  • Modern project structure

Installing Go Blueprint

$ go install github.com/melkeydev/go-blueprint@latest

# Verify installation
$ go-blueprint version

Creating A New Project

Interactive Mode

$ go-blueprint create

# Follow the prompts:
# 1. Enter project name
# 2. Select framework (Gin, Fiber, etc.)
# 3. Choose database (PostgreSQL, MySQL, etc.)
# 4. Enable Docker? (y/n)
# 5. Add GitHub Actions? (y/n)

Command-Line Mode

$ go-blueprint create \
  --name myapp \
  --framework gin \
  --driver postgres \
  --advanced \
  --feature docker \
  --feature githubaction \
  --git commit

Project Structure Generated

myapp/
├── cmd/
│   └── api/
│       └── main.go
├── internal/
│   ├── database/
│   │   └── database.go
│   ├── handlers/
│   │   └── handler.go
│   └── models/
│       └── model.go
├── .github/
│   └── workflows/
│       └── go.yml
├── Dockerfile
├── docker-compose.yml
├── Makefile
├── go.mod
├── go.sum
└── README.md

Framework Options

$ go-blueprint create --framework gin

Fast HTTP web framework with middleware support.

Chi (Lightweight)

$ go-blueprint create --framework chi

Lightweight, idiomatic router based on net/http.

Standard Library (Minimal)

$ go-blueprint create --framework standard-library

Pure standard library, no dependencies.

Database Integration

Blueprint generates database connection and migration setup:

// internal/database/database.go
package database

import (
    "database/sql"
    // Blank import registers the pgx driver with database/sql
    _ "github.com/jackc/pgx/v5/stdlib"
)

func New() (*sql.DB, error) {
    // Use "pgx" as the driver name (registered by the blank import above)
    db, err := sql.Open("pgx", "postgres://user:password@localhost:5432/dbname?sslmode=disable")
    if err != nil {
        return nil, err
    }
    return db, nil
}

Includes:

  • Connection pooling configuration
  • Health check endpoints
  • Migration examples

Docker Support

Dockerfile

FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY . .
RUN go mod download
RUN go build -o main cmd/api/main.go

FROM alpine:latest
WORKDIR /root/
COPY --from=builder /app/main .
EXPOSE 8080
CMD ["./main"]

docker-compose.yml

version: '3.8'
services:
  app:
    build: .
    ports:
      - "8080:8080"
  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_PASSWORD: password

Makefile Included

.PHONY: build run test clean docker-build docker-run

build:
	@go build -o bin/main cmd/api/main.go

run:
	@go run cmd/api/main.go

test:
	@go test -v ./...

clean:
	@rm -rf bin/

docker-build:
	@docker build -t myapp .

docker-run:
	@docker-compose up

CI/CD With GitHub Actions

Blueprint generates .github/workflows/go.yml:

name: Go

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

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

    - name: Set up Go
      uses: actions/setup-go@v5
      with:
        go-version: '1.25'

    - name: Build
      run: go build -v ./...

    - name: Test
      run: go test -v ./...

Getting Started After Creation

# Create project with database, Docker, and GitHub Actions
$ go-blueprint create \
    --name myapp \
    --framework gin \
    --driver postgres \
    --advanced \
    --feature docker \
    --feature githubaction \
    --git commit

# Navigate to project
$ cd myapp

# Install dependencies
$ go mod tidy

# Run the application
$ make run

# Or with Docker
$ make docker-run

Visit: http://localhost:8080

Go Blueprint Best Practices

  1. Choose the right framework - Consider project complexity
  2. Start simple - Add features as needed
  3. Read generated code - Understand the structure
  4. Customize to your needs - Blueprint is a starting point
  5. Keep dependencies updated - Run go get -u regularly
  6. Use the Makefile - Consistent commands across team
  7. Review CI/CD setup - Adjust for your workflow

Exercise 1 - Hot Reload Setup (15 Minutes)

  1. Create a simple HTTP server in main.go: “`go package main

import ( “fmt” “net/http” )

func handler(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, “Hello, World! Version 1.0”) }

func main() { http.HandleFunc(“/”, handler) log.Fatal(http.ListenAndServe(“:8080”, nil)) } “`

  1. Install air: go install github.com/cosmtrek/air@latest
  2. Initialize air: air init
  3. Start air: air
  4. Modify the response message and watch it reload automatically
  5. Experiment with the .air.toml configuration

Exercise 2 - Goroutine Leak Detection (15 Minutes)

  1. Create a test file with a goroutine leak: “`go package main

import ( “testing” “time” “go.uber.org/goleak” )

func TestWithLeak(t *testing.T) { defer goleak.VerifyNone(t)

   // This goroutine will leak!
   go func() {
       time.Sleep(10 * time.Second)
   }()

} “`

  1. Run the test and observe the leak detection
  2. Fix the leak using a context or WaitGroup
  3. Add goleak to TestMain for package-level verification
  4. Experiment with ignore options

Summary

Modern Go development requires:

Hot reload with air for rapid iteration ✅ Leak detection with goleak for reliability ✅ Build tags for flexible compilation ✅ Build info for production debugging ✅ VSCode integration for productivity ✅ Project bootstrapping with Go Blueprint

These tools form the foundation of professional Go development workflows.

Questions?

  • Air: Live reloading for Go applications
  • goleak: Goroutine leak detection
  • Build tags: Conditional compilation
  • Build info: Runtime version information
  • VSCode: Modern IDE with Go support
  • Go Blueprint: Project scaffolding tool