GitHub Copilot for Go

GitHub Copilot Programming LanguagesAcademy lesson 33Cluster 3 · Lesson 8 of 13Intermediate12 min readVersion-sensitive
Published
Updated
Last technically verified
GitHub Copilot for GoGitHub Copilot Programming Languages8Intermediate/github-copilot/languages/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.

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, explicitly

and 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 so errors.Is and errors.As work upstream. A suggestion using %v instead of %w silently breaks that.
  • Sentinel comparison. err == ErrNotFound fails once anything wraps it; errors.Is(err, ErrNotFound) does not. Generated code uses the former.
  • A nil error with a non-nil typed value. Returning a concrete pointer type into an error interface produces a non-nil interface holding a nil pointer, and err != nil is 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 in main, not in a handler.
  • A missing defer cancel(). context.WithTimeout returns a cancel function that must be called, or the timer leaks. go vet catches 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

copilot-go-demo/ ├── go.mod ├── main.go ├── handler.go └── handler_test.go

The prompt

Copilot promptGenerate the handlerCopilot Chat, with go.mod open

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.Float64s sorts 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 JavaScript sort() 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 NaN and Inf checks. JSON numbers decode into float64, and a comparison against a NaN is always false, so s < 0 alone 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.Run for subtests, so a failure names the case rather than the line.
  • t.Cleanup rather than defer for teardown that must survive a subtest.
  • -race always. 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 replace directive left in go.mod from local development will not work for anyone else and is a common accidental commit.
  • The go directive version. It controls language features and, in recent toolchains, which toolchain gets used. A suggestion that uses a newer language feature than your go.mod declares 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

Accepting a Go suggestion
  1. Run go vet and go test -raceTwo commands, seconds, and they cover the mechanical layer plus races that execute.
  2. 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.
  3. Read every `go` statementHuman judgementWhat stops it, who waits for it, what does it share, where does its error go?
  4. Follow the contextHuman judgementDoes it reach the call that actually blocks, or stop at the signature?
  5. Check zero valuesHuman judgementWhich omitted fields are legitimately zero, and which mean 'unset'?
  6. 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 -race as part of the normal test command, not as an occasional extra.
  • Add staticcheck alongside go vet; it finds a wider class of real bugs.
  • Push back on producer-side interfaces with a single implementation.
  • Name context.Context in 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.Get in 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.

Primary sources