GitHub Copilot for Go
Go is the most uniform language in this cluster. gofmt settled formatting
arguments a decade ago, the standard library covers most of what a service needs,
and the community converged early on a small set of idioms. Public Go code is
consequently more homogeneous than public code in any other language here.
That homogeneity is good news for suggestion quality — the model’s prior is much closer to correct Go than its prior for PHP or C++ is for those languages.
It also creates the specific failure this lesson is about. When a suggestion is not idiomatic Go, it is usually because the model has imported a habit from another language — a wrapped exception hierarchy, a class-like struct with getters and setters, a deep interface hierarchy defined up front. That code compiles, passes review from someone who does not write much Go, and is wrong in the way that costs you six months later.
Go quick referenceVerified August 21, 2026
- Type system
- Statically typed with structural interfaces and no exceptions; every fallible call returns an error you are expected to handle.
- Package management
- Go modules
- Manifest Copilot should see
- go.mod
- Testing
- the standard testing package
- Formatting
- gofmt (via go fmt)
- Linting
- go vet
- Static analysis
- staticcheck, go vet, the race detector
- Common frameworks
- net/http, chi, gRPC-Go
- Typical Copilot work
- HTTP handlers, table-driven tests, struct definitions, context plumbing, CLI flags
- First thing to review
- Discarded errors, and goroutines with no cancellation path
Fastest honest checkgo vet ./... && go test -race ./...
Key takeaways
- The two Go-specific defects worth grepping for on every review are a discarded error and a goroutine with no cancellation path.
context.Contextshould be the first parameter of anything that does I/O, and it should actually reach the call that blocks. Generated handlers accept one and then ignore it.- Go’s zero values are meaningful, which means an omitted field is a valid value rather than an error. Generated struct literals rely on this without saying so.
- Table-driven tests are the Go idiom and produce far better coverage from a prompt than “write tests for this function” does.
- The fastest honest check is
go vet ./... && go test -race ./....
Errors are values, and generated code forgets it
Go has no exceptions. Every fallible call returns an error alongside its result, and handling it is the caller’s job — stated in the signature, checked by nobody.
That last part is the problem. if err != nil is a convention the compiler does
not enforce, except in one narrow case: an unused variable. So this compiles:
data, _ := os.ReadFile(path) // the error is discarded, explicitlyand so does this, which is worse because it reads as if something was handled:
resp, err := http.Get(url)
if err != nil {
log.Println(err) // logged, then execution continues
}
defer resp.Body.Close() // resp is nil here. Panic.Both patterns appear in generated Go, because both appear in public Go. The review questions are always the same two: is every error checked, and does handling it actually stop the failing path.
Three more error-handling specifics worth checking:
- Wrapping.
fmt.Errorf("reading config: %w", err)preserves the chain soerrors.Isanderrors.Aswork upstream. A suggestion using%vinstead of%wsilently breaks that. - Sentinel comparison.
err == ErrNotFoundfails once anything wraps it;errors.Is(err, ErrNotFound)does not. Generated code uses the former. - A
nilerror with a non-niltyped value. Returning a concrete pointer type into anerrorinterface produces a non-nil interface holding a nil pointer, anderr != nilis then true when nothing went wrong. Rare, confusing, and worth knowing the shape of.
Contexts that actually cancel
context.Context is Go’s cancellation and deadline mechanism, and generated code
treats it as decorative more often than not.
The pattern to look for: a handler signature accepts ctx context.Context, and
then the body calls http.Get, db.Query or time.Sleep — none of which take
the context. The request is cancelled, the client disconnects, and the work keeps
going.
The correct forms are http.NewRequestWithContext, db.QueryContext, and a
select on ctx.Done() for anything that waits. Ask for them by name.
Two related checks:
context.Background()inside a request path. That creates a context which is never cancelled. It belongs inmain, not in a handler.- A missing
defer cancel().context.WithTimeoutreturns a cancel function that must be called, or the timer leaks.go vetcatches the obvious cases.
Concurrency: the category that needs the most suspicion
Goroutines are cheap to start and easy to leak, and a leaked goroutine produces no error at all — just memory that never comes back.
Every goroutine needs an answer to “how does this end?” A go func() started
in a handler with no cancellation, no WaitGroup and no bounded channel is a
leak. In a service handling requests, it is a leak per request.
Channel deadlocks. An unbuffered channel send with no receiver blocks
forever. A range over a channel nobody closes blocks forever. Generated
producer/consumer code frequently omits the close, and the symptom is a
goroutine that never exits rather than an error.
Data races. Two goroutines touching the same variable without
synchronisation. Go’s race detector finds these reliably when the racing code
executes, which is why -race belongs in your normal test command rather than in
an occasional check.
Loop variable capture. Long the most notorious Go gotcha — a goroutine
capturing the loop variable saw the final value rather than the per-iteration one.
Recent Go versions changed the loop variable to be per-iteration, which fixes it.
The relevance for generated code is that public Go contains vast quantities of the
old workaround (item := item shadowing at the top of the loop). Seeing it is
harmless; not seeing it is now also fine. What still matters is whether the
goroutine is waited for at all.
Practical project: a small HTTP API
Practical example
An HTTP handler with context, real error handling and table-driven tests
The shape a well-prompted Go suggestion should take, and the specific things to check in it.
- Status
- Example implementation
- Not executed here
- This code was written against the documentation cited at the end of the lesson but was not run while writing it. Treat the commands as the ones to run, not as output that has been observed.
- Requires
- A Go toolchain (1.22 or later). No third-party dependencies.
Files
copilot-go-demo/ ├── go.mod ├── main.go ├── handler.go └── handler_test.go
The prompt
Go, standard library only, net/http.
POST /latencies accepts JSON with a “samples” field: an array of non-negative float64. Respond with count, p50, p95 and p99 using nearest-rank percentiles.
Return 400 with a JSON error for a missing, empty, or invalid samples field.
Limit the request body to 16KB. Take a context and honour it.
Every error must be checked. Do not log and continue.
Then write table-driven tests using httptest, covering the empty array, a single value, an unsorted input, a negative value and malformed JSON.
Implementation
package main
import (
"encoding/json"
"errors"
"math"
"net/http"
"sort"
)
type request struct {
Samples []float64 `json:"samples"`
}
type report struct {
Count int `json:"count"`
P50 float64 `json:"p50"`
P95 float64 `json:"p95"`
P99 float64 `json:"p99"`
}
var errInvalidSamples = errors.New("samples must be a non-empty array of non-negative numbers")
func percentiles(samples []float64) report {
sorted := append([]float64(nil), samples...) // copy: do not sort the caller's slice
sort.Float64s(sorted)
at := func(p float64) float64 {
i := int(math.Ceil(p/100*float64(len(sorted)))) - 1
return sorted[max(0, min(i, len(sorted)-1))]
}
return report{Count: len(sorted), P50: at(50), P95: at(95), P99: at(99)}
}
func handleLatencies(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var req request
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if len(req.Samples) == 0 {
writeError(w, http.StatusBadRequest, errInvalidSamples.Error())
return
}
for _, s := range req.Samples {
if s < 0 || math.IsNaN(s) || math.IsInf(s, 0) {
writeError(w, http.StatusBadRequest, errInvalidSamples.Error())
return
}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(percentiles(req.Samples)); err != nil {
// The status is already written; all that is left is to record it.
return
}
}Four details worth naming, because a first suggestion typically misses each:
- The slice copy before sorting.
sort.Float64ssorts in place, and the slice header shares the caller’s backing array. Sorting it is a side effect the caller did not ask for — the Go equivalent of the JavaScriptsort()mutation covered in that lesson. http.MaxBytesReader. Without it, a client can send an arbitrarily large body and the decoder will try to hold it.DisallowUnknownFields. Without it, a typo in the client’s field name is silently ignored and the field arrives as its zero value.- The
NaNandInfchecks. JSON numbers decode intofloat64, and a comparison against aNaNis always false, sos < 0alone lets it through.
Tests
func TestHandleLatencies(t *testing.T) {
tests := []struct {
name string
body string
wantStatus int
wantP95 float64
}{
{"valid unsorted", `{"samples":[12,40,7,900,33]}`, 200, 900},
{"single value", `{"samples":[5]}`, 200, 5},
{"empty array", `{"samples":[]}`, 400, 0},
{"negative value", `{"samples":[1,-2]}`, 400, 0},
{"malformed json", `{not json`, 400, 0},
{"unknown field", `{"sampels":[1]}`, 400, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/latencies",
strings.NewReader(tt.body))
rec := httptest.NewRecorder()
handleLatencies(rec, req)
if rec.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d", rec.Code, tt.wantStatus)
}
if tt.wantStatus != http.StatusOK {
return
}
var got report
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decoding response: %v", err)
}
if got.P95 != tt.wantP95 {
t.Errorf("p95 = %v, want %v", got.P95, tt.wantP95)
}
})
}
}Run
go vet ./...
go test -race ./...Testing
Go’s testing package is in the standard library, which makes generated tests consistent — there is only one thing to target.
The idiom worth asking for by name is the table-driven test, as above. A prompt that says “table-driven, with a named case per row” produces a structure where the missing case is visible; “write tests” produces three functions and a false sense of coverage.
Three more Go-specific test points:
t.Runfor subtests, so a failure names the case rather than the line.t.Cleanuprather thandeferfor teardown that must survive a subtest.-racealways. It is the only mechanical check for the language’s most dangerous defect class, and it costs a few seconds.
httptest covers handler testing without binding a port, and generated code will
use it when asked. Left alone, suggestions sometimes start a real server on a
fixed port, which breaks under parallel test execution.
Modules, dependencies and a standard library that is usually enough
Go’s dependency story is the least dramatic in this cluster, and the reason is
worth naming: go.mod records exactly what you depend on, go.sum records
cryptographic hashes of every module version, and the toolchain verifies them on
every build. A hallucinated import does not resolve, and a tampered module does
not verify.
That removes most of the supply-chain anxiety that JavaScript and PHP carry. Two Go-specific issues take its place.
Module paths are URLs, and a plausible one may exist. github.com/someone/ mux and github.com/someoneelse/mux are both valid module paths, and a
suggestion that picks the wrong one produces code that builds against a package
you did not intend to depend on. Read the module path, not just the package name
in the import block.
The standard library is frequently the right answer, and suggestions reach past
it. Public Go contains a decade of code written before the standard library
grew routing patterns, structured logging and generics. So a suggestion will
often import a third-party router, a third-party logging library or a
third-party assertion package for something net/http, log/slog and testing
now handle directly. None of those imports is wrong; each is a dependency you
did not need, and dependencies are the thing you review forever.
The prompt clause that handles this: “standard library only, unless something genuinely requires a dependency — in which case name it and say why.” As in every other language in this cluster, asking the model to justify a dependency produces either a good reason or the version without it.
Two smaller module points worth checking in generated Go:
- A
replacedirective left ingo.modfrom local development will not work for anyone else and is a common accidental commit. - The
godirective version. It controls language features and, in recent toolchains, which toolchain gets used. A suggestion that uses a newer language feature than yourgo.moddeclares fails with a message about the language version rather than about the feature.
Interfaces, structs and zero values
Two Go design habits that generated code gets wrong in a consistent direction.
Interfaces belong to the consumer, not the producer. Go’s interfaces are satisfied implicitly, so the idiom is to define a small interface where it is used, listing only the methods that caller needs. Generated code frequently defines a large interface next to its single implementation — a habit imported from Java and C#. If a suggested interface has one implementation and eight methods, it is ceremony.
Zero values are meaningful. A struct field omitted from a literal is not
missing — it is zero, and zero is a valid value. That is deliberate Go design and
it interacts badly with generated code that builds a struct from partial input:
an absent Timeout is not “use the default”, it is zero, which for a
time.Duration means no timeout at all. Where zero is not a valid value, use a
pointer or an explicit Valid bool, and say so in the prompt.
Related: an uninitialised map is nil, and writing to it panics while reading
from it returns the zero value. Generated code that declares var m map[string]int
and then assigns into it will compile and panic.
Debugging and refactoring
Go panics print a goroutine dump, and on a busy service that dump can run to thousands of lines across hundreds of goroutines. This is one of the better uses of chat in any language: paste the whole thing and ask which goroutine is blocked on what. The dump is precise, structured context, and identifying the one goroutine holding a lock that forty others are waiting on is a reading task rather than a reasoning one.
The same applies to a race detector report. Its output names two stacks — the write and the conflicting access — and asking for an explanation of how the two interleave is faster than reconstructing it yourself.
Refactoring in Go sits between the dynamic languages and
Rust. The compiler catches signature changes
and missing methods, so a mechanical rename or an extracted function is verified.
What it does not catch is the thing Go leaves to convention: a refactor that
moves a call out from under a defer, changes when a context is cancelled, or
alters which goroutine owns a piece of state will compile perfectly.
The practical rule: after any refactor that touches concurrency or resource
lifetime, re-run the tests with -race rather than assuming a clean build means
a clean change. gofmt and goimports handle the mechanical tidying, so the
diff you review contains only real changes — which matters more with generated
code than with hand-written code, for the same reason it does everywhere else in
this cluster.
Go-specific risks
Ignored errors. The headline risk. errcheck and go vet catch much of it.
Goroutine leaks and channel deadlocks. Covered above; no tool catches these reliably, so they are read manually.
Missing context cancellation. Covered above.
Data races. -race in every test run.
Non-idiomatic abstraction. Producer-side interfaces, getters and setters, deep package hierarchies. Not a correctness bug; a maintenance one.
Zero-value assumptions. Covered above.
Slice aliasing. append may or may not reallocate, so two slices can share a
backing array until one of them grows. A function that appends to a slice it
received and returns the result is fine; one that appends and relies on the
caller seeing the change is not.
Review workflow
- Run go vet and go test -raceTwo commands, seconds, and they cover the mechanical layer plus races that execute.
- Grep the diff for `_ =` and `err != nil` blocks that only logHuman judgementEvery discarded or logged-and-continued error is a decision that needs a reason.
- Read every `go` statementHuman judgementWhat stops it, who waits for it, what does it share, where does its error go?
- Follow the contextHuman judgementDoes it reach the call that actually blocks, or stop at the signature?
- Check zero valuesHuman judgementWhich omitted fields are legitimately zero, and which mean 'unset'?
- Confirm any HTTP client has a timeoutHuman judgement
Steps marked Human judgement are the ones that do not get delegated — they are where you decide whether what Copilot produced is actually right.
Best practices
- Ask explicitly for checked, wrapped errors and no logging-and-continuing.
- Ask for table-driven tests with named cases.
- Run
-raceas part of the normal test command, not as an occasional extra. - Add
staticcheckalongsidego vet; it finds a wider class of real bugs. - Push back on producer-side interfaces with a single implementation.
- Name
context.Contextin the prompt and check it is threaded through.
Common mistakes
- Accepting
_for an error because the code path “cannot fail”. - Logging an error and continuing with a nil result.
- Starting a goroutine with no answer to how it ends.
- Sorting or appending to a slice the caller still holds.
- Using
http.Getin a service and inheriting no timeout.
Where to go next
GitHub Copilot for Rust is the useful contrast: a language that makes the compiler answer the questions Go leaves to you. GitHub Copilot for Bash covers the operational scripting that surrounds most Go services, and both are the on-ramp to the upcoming DevOps and infrastructure cluster.
Sources
Every version-sensitive claim on this page was checked against first-party documentation. Only sources actually used are listed.
Your progress
Saved in this browser only. No account, no server, and nothing leaves your device. Clearing site data resets it.
Was this lesson helpful?
Your answer is stored in this browser and is not sent anywhere.