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
- Use TestMain for package-level verification
- Ignore known goroutines from dependencies
- Test cleanup logic explicitly
- Use contexts for goroutine cancellation
- 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())
}
}
package main
import (
"bytes"
"runtime"
"runtime/pprof"
"strings"
"testing"
"time"
)
// section: test
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())
}
}
// section: test
// section: check
func checkForLeaks(t *testing.T) {
t.Helper()
prof := pprof.Lookup("goroutineleak")
if prof == nil {
return // Profile not available
}
runtime.GC()
time.Sleep(50 * time.Millisecond)
var buf bytes.Buffer
prof.WriteTo(&buf, 2)
if strings.Contains(buf.String(), "(leaked)") {
t.Error("Goroutine leak detected!")
t.Log(buf.String())
}
}
// section: checkProduction 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 formatdebug=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
- Use new syntax (
//go:build) for Go 1.17+ - Keep both forms for compatibility
- Document required tags in README
- Use consistent naming (dev, prod, integration)
- Separate test files with build tags
- 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
- Open VSCode
- Press
Ctrl+Shift+X(orCmd+Shift+Xon Mac) - Search for “Go”
- 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:
- Open Command Palette (
Ctrl+Shift+PorCmd+Shift+P) - Type:
Go: Install/Update Tools - 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(orCmd+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
- Set breakpoint (click left of line number)
- Press
F5or click “Debug” abovemainfunction - 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
F12orCtrl+Clickon symbol- See definition in peek window or navigate to file
Find All References
Shift+F12on 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
- Select code
- Right-click → “Refactor…”
- Choose “Extract to function”
Rename Symbol
F2on symbol- Renames across entire workspace
- Updates all references
Generate Interface Stubs
Command Palette → Go: Generate Interface Stubs
VSCode Go Best Practices
- Enable format on save - Consistent code style
- Use gopls - Modern language server
- Configure linter - Catch issues early
- Learn keyboard shortcuts - Faster development
- Use test explorer - Better test management
- Set up debugging - Debug efficiently
- 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
Gin (Popular)
$ 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
- Choose the right framework - Consider project complexity
- Start simple - Add features as needed
- Read generated code - Understand the structure
- Customize to your needs - Blueprint is a starting point
- Keep dependencies updated - Run
go get -uregularly - Use the Makefile - Consistent commands across team
- Review CI/CD setup - Adjust for your workflow
Additional Resources
Official Documentation
Community
Exercise 1 - Hot Reload Setup (15 Minutes)
- 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)) } “`
- Install air:
go install github.com/cosmtrek/air@latest - Initialize air:
air init - Start air:
air - Modify the response message and watch it reload automatically
- Experiment with the
.air.tomlconfiguration
Exercise 2 - Goroutine Leak Detection (15 Minutes)
- 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)
}()
} “`
- Run the test and observe the leak detection
- Fix the leak using a context or WaitGroup
- Add goleak to TestMain for package-level verification
- 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