Go Development with Claude Code: go.mod, Tests, Concurrency, and Race Checks
Practical Claude Code workflow for Go: repo mapping, go.mod, go work, API/CLI, tests, context, races, benchmarks.
When you use Claude Code for Go development, the risky prompt is “build an API” or “add tests” with no repository context. You may get code that runs, but it can drift from the existing go.mod, package boundaries, error policy, context propagation, and concurrency safety. Go looks simple, so these mistakes often hide inside small diffs.
This guide treats Claude Code as a Go development agent, not only a code generator. The workflow covers repository mapping, module and workspace decisions, API and CLI changes, table-driven tests, error wrapping, context cancellation, concurrency pitfalls, the race detector, benchmarks, and safe prompts.
Masa tried this on a small Go task API. The first prompt only said “add a handler”, and the result worked locally but had no tests, unclear cancellation behavior, and a risky shared map write from goroutines. After changing the prompt to “read the repo and verify with go test -race”, the review became much more concrete.
Map The Repository First
The first Go task is not code generation. It is a repository map: which directories contain commands, which packages are internal, which go.mod controls dependencies, and what CI already checks. Ask Claude Code to spend the first pass on that map before editing files.
pwd
find . -name go.mod -o -name go.work -o -name "*.go" | sort | sed -n '1,120p'
go env GOMOD GOWORK
go list -m
go list ./...
go test ./...
Use a scoped discovery prompt:
Inspect this Go repository. Do not edit files yet.
Report:
- Whether go.mod and go.work exist
- The purpose of cmd, internal, pkg, api, migrations, and testdata
- Public types/functions that may break compatibility if changed
- The existing error handling style
- Boundaries that accept context.Context
- The result of go test ./...
- The smallest set of files safe to touch for the next task
For official Go references, use Organizing a Go module for layout guidance and the Go Modules Reference for dependency behavior. Use the Claude Code overview for Claude Code itself.
flowchart LR
A["repo map"] --> B["go.mod / go.work"]
B --> C["API or CLI change"]
C --> D["table-driven tests"]
D --> E["go test -race"]
E --> F["benchmark and review"]
For adjacent Claude Code workflows, see repo mapping for existing codebases and CLAUDE.md best practices. The same rule applies to Go: fix the working agreement before asking for implementation.
Do Not Let go.mod And go.work Drift
go.mod records the module path, Go version, and dependencies for a module. A small application often needs only one go.mod. A multi-module repository may use go.work so the Go command can work with several local modules at the same time. The official tutorial is Getting started with multi-module workspaces.
Before allowing Claude Code to add dependencies, inspect the current module state:
go env GOMOD GOWORK
go list -m all
go mod tidy
go test ./...
Only create a workspace when you truly need to edit multiple modules together:
mkdir -p services/taskapi tools/taskctl
cd services/taskapi
go mod init example.com/acme/taskapi
cd ../..
cd tools/taskctl
go mod init example.com/acme/taskctl
cd ../..
go work init ./services/taskapi ./tools/taskctl
go work use ./services/taskapi ./tools/taskctl
go work sync
The pitfall is using go.work as a hidden dependency trick. Code can pass locally because a workspace points at local modules, then fail in CI or on another developer machine. Decide whether go.work is committed team infrastructure or personal local state, and tell Claude Code to explain any new module or dependency before changing it.
Share A Service Layer Between API And CLI
In Go, a common shape is to place executables under cmd/ and reusable business logic under internal/. The example below keeps the core Store independent from HTTP so the same behavior can later be used by a CLI. It uses only the standard library. Paste it into cmd/taskapi/main.go.
// cmd/taskapi/main.go
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"sync"
"time"
)
var ErrValidation = errors.New("validation failed")
type Task struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
}
type Store struct {
mu sync.Mutex
next int
tasks map[string]Task
}
func NewStore() *Store {
return &Store{next: 1, tasks: make(map[string]Task)}
}
func (s *Store) Create(ctx context.Context, title string) (Task, error) {
select {
case <-ctx.Done():
return Task{}, fmt.Errorf("create task canceled: %w", ctx.Err())
default:
}
title = strings.TrimSpace(title)
if title == "" {
return Task{}, fmt.Errorf("%w: title is required", ErrValidation)
}
s.mu.Lock()
defer s.mu.Unlock()
task := Task{
ID: fmt.Sprintf("task-%06d", s.next),
Title: title,
Status: "open",
CreatedAt: time.Now().UTC(),
}
s.next++
s.tasks[task.ID] = task
return task, nil
}
func (s *Store) List(ctx context.Context) ([]Task, error) {
select {
case <-ctx.Done():
return nil, fmt.Errorf("list tasks canceled: %w", ctx.Err())
default:
}
s.mu.Lock()
defer s.mu.Unlock()
tasks := make([]Task, 0, len(s.tasks))
for _, task := range s.tasks {
tasks = append(tasks, task)
}
return tasks, nil
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
store := NewStore()
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) {
tasks, err := store.List(r.Context())
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, tasks)
})
mux.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) {
var body struct {
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
task, err := store.Create(r.Context(), body.Title)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusCreated, task)
})
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
logger.Info("taskapi listening", "addr", server.Addr)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("server failed", "error", err)
os.Exit(1)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
logger.Error("graceful shutdown failed", "error", err)
}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrValidation):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
writeJSON(w, http.StatusRequestTimeout, map[string]string{"error": err.Error()})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
}
}
Run it with:
go mod init example.com/taskapi
mkdir -p cmd/taskapi
go run ./cmd/taskapi
curl -s http://localhost:8080/tasks
curl -s -X POST http://localhost:8080/tasks \
-H 'content-type: application/json' \
-d '{"title":"write table-driven tests"}'
If you add a CLI later, do not copy the handler logic. Call the same service layer. The companion article building CLI tools with Claude Code covers that angle.
Make Table-Driven Tests Part Of Done
A table-driven test lists inputs and expected results in a slice, then runs the same test logic for each row. It is a natural Go pattern for validation, errors, and edge cases. Ask Claude Code for the exact cases, not just “write tests”.
// cmd/taskapi/store_test.go
package main
import (
"context"
"errors"
"fmt"
"testing"
)
func TestStoreCreate(t *testing.T) {
tests := []struct {
name string
title string
wantErr error
}{
{name: "valid title", title: "ship release notes"},
{name: "blank title", title: " ", wantErr: ErrValidation},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := NewStore()
task, err := store.Create(context.Background(), tt.title)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("error = %v, want %v", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if task.ID == "" || task.Status != "open" {
t.Fatalf("unexpected task: %+v", task)
}
})
}
}
func TestStoreCreateHonorsCanceledContext(t *testing.T) {
store := NewStore()
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := store.Create(ctx, "will not be created")
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context.Canceled", err)
}
}
func BenchmarkStoreCreate(b *testing.B) {
store := NewStore()
ctx := context.Background()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if _, err := store.Create(ctx, fmt.Sprintf("task-%d", i)); err != nil {
b.Fatal(err)
}
}
}
Make these commands part of the acceptance criteria:
gofmt -w cmd/taskapi
go test ./...
go test -race ./...
go test -run TestStoreCreate -count=1 ./...
go test -bench=. -benchmem ./...
The official testing package documents tests and benchmarks. A benchmark is a measurement loop controlled by Go through b.N; it helps avoid decisions based on “it feels faster”.
Preserve Context Cancellation
context.Context carries cancellation, deadlines, and request-scoped values across API boundaries. The official context package explains that incoming server requests should create a Context and outgoing calls should accept one.
A common Claude Code mistake is receiving r.Context() in a handler, then creating context.Background() again inside a service or repository. That breaks cancellation. If the client disconnects, your database query or downstream API call may continue anyway. Put this rule in the prompt: do not create a new background context in lower layers; pass the caller’s ctx.
Another mistake is forgetting to call the cancel returned by context.WithTimeout. Tell Claude Code to use defer cancel() and run go vet when it introduces derived contexts.
Check Concurrency With The Race Detector
Goroutines are lightweight, but shared data still needs synchronization. A data race occurs when goroutines access the same variable concurrently, at least one access is a write, and there is no synchronization. The official Data Race Detector runs with go test -race. It only finds races on code paths that execute.
Avoid concurrent writes to a shared map like this:
func CountByStatusBad(tasks []Task) map[string]int {
counts := make(map[string]int)
var wg sync.WaitGroup
for _, task := range tasks {
task := task
wg.Add(1)
go func() {
defer wg.Done()
counts[task.Status]++
}()
}
wg.Wait()
return counts
}
Protect the shared state with a mutex:
func CountByStatus(tasks []Task) map[string]int {
counts := make(map[string]int)
var (
mu sync.Mutex
wg sync.WaitGroup
)
for _, task := range tasks {
task := task
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counts[task.Status]++
mu.Unlock()
}()
}
wg.Wait()
return counts
}
The prompt should not merely say “make this concurrent”. Ask Claude Code to identify shared variables, channel close ownership, cancellation behavior, WaitGroup correctness, and the go test -race result. For review framing, see Claude Code code review practice.
Safe Claude Code Prompts For Go
Good Go prompts define edit scope, contracts, verification commands, and forbidden changes:
Goal: add validation and tests for POST /tasks in taskapi.
Files allowed:
- cmd/taskapi/main.go
- cmd/taskapi/store_test.go
Rules:
- Do not change the go.mod module path
- Do not add new external dependencies
- Do not rename public types or JSON fields
- Propagate context.Context from handler to service
- Wrap errors with fmt.Errorf("%w") and check them with errors.Is
Done means:
- gofmt -w cmd/taskapi
- go test ./...
- go test -race ./...
- Report the change and remaining risk briefly
When a dependency may be needed, force a pause:
If you believe an external library is necessary, explain the candidate, why it is needed, why the standard library is not enough, and the go.mod impact before implementation. Do not run go get without approval.
This prevents surprise additions of CLI frameworks, routers, ORMs, or mocking libraries. Go often gets far with the standard library, so dependency changes deserve review.
Use Cases And Pitfalls
The first use case is adding a small endpoint to an existing API. Include the handler, service method, table-driven tests, and go test -race in one task. The pitfall is adding only the handler and breaking the existing service boundary or error envelope.
The second use case is sharing logic between a CLI and an API. If operations staff run a CLI and the admin UI calls an API for the same rule, move the rule into internal/service. The pitfall is copying logic into the CLI and letting it drift.
The third use case is parallelizing slow aggregation. Dashboard code that calls three external APIs can benefit from goroutines. The pitfalls are shared slice or map writes, multiple goroutines closing the same channel, and goroutine leaks after cancellation.
The fourth use case is performance work. Before asking Claude Code to “make it faster”, collect a baseline with go test -bench=. -benchmem. The pitfall is adding cache or goroutines without measurement, increasing memory use and complexity.
The fifth use case is multi-module repository work. go.work can make local edits convenient, but you must confirm go env GOWORK and run tests from the same place CI will run them. The pitfall is assuming a local workspace pass is production evidence.
Monetization CTA And Next Step
Readers using Claude Code for Go usually care about safe adoption in a real team repository. If you want a quick personal workflow, start with the free cheatsheet. If you want CLAUDE.md, permission rules, review prompts, and Go checklists packaged together, see the products and templates. For team adoption across Go APIs, CLIs, CI, and review rules, use Claude Code training and consultation.
Summary
The stable Go workflow is: map the repository first, inspect go.mod and go.work, keep API and CLI changes behind a service layer, make table-driven tests part of done, preserve context cancellation, run the race detector, and benchmark before claiming performance wins.
I tested this workflow on a small task API. The single highest-leverage change was putting verification commands directly into the prompt. Once gofmt, go test ./..., go test -race ./..., and go test -bench=. -benchmem became completion criteria, Claude Code’s final report changed from “implemented” to “verified, with these remaining risks.” Before publishing, also check official links, internal links, body depth, code fences, updatedDate, and heroImage.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.