Go is defined more by what it refuses than by what it offers. No inheritance, no exceptions, no implicit conversions, no constructors, and for its first decade no generics. Each refusal replaces a choice with a convention, which is the actual product: unfamiliar Go reads like the Go you already know. So the fastest way in is not a syntax tour but a list of what was removed and what you use instead. Nearly everything that frustrates newcomers is one of these refusals meeting a habit from Java or Python — reaching for a try block, expecting a subclass, expecting a warning where Go gives a build failure.
Each chapter opens with the short version. Tap one to read the detail.
The compiler as a co-author
~2 min
An unused variable or import is a build error, not a warning. Visibility is decided entirely by whether a name starts with a capital letter. Both rules exist to remove a decision, and both catch newcomers who expect the compiler to be advisory.
A package is a directory: every file in it opens with the same package clause and shares one namespace. Whether something is visible outside that package is decided by its first letter — capitalized is exported, anything else is not. There is no public, no private, no export list.
The rule that surprises people arriving from other languages is that the compiler refuses to build code you would elsewhere merely be warned about. Leave a variable unused and you get declared and not used: x; leave an import unused and you get "os" imported and not used. Neither is configurable. The reasoning is that both usually mean a thought you did not finish, and a warning nobody reads is not a check.
The trap this creates is shadowing. Inside a nested block, := declares a new variable rather than assigning the outer one, and because the new one is used, nothing complains.
f, err := os.Open(path)
if err == nil {
data, err := io.ReadAll(f) // NEW err, scoped to this block
_ = data
}
// the outer err is still whatever Open returned
Use = when you mean to assign, and reach for _ to discard deliberately rather than to silence a message you have not read.
Rule: treat a refusal to compile as the compiler naming an unfinished thought — finish it or discard it explicitly, never work around it.
Distinct types, explicit conversions, and the constants that soften them
~2 min
Every variable is usable the moment it is declared, because every type has a zero value. A defined type is genuinely new even when its underlying type is familiar, so mixing it with that underlying type needs an explicit conversion — and untyped constants are what stop that rule being unbearable.
There is no uninitialised memory. A declared variable is 0, "", false, or nil, all the way down through a struct's fields, so a type can be designed to be useful at zero — sync.Mutex is ready to lock, bytes.Buffer ready to write.
type Celsius float64 creates a new type, not a nickname. Assigning a plain float64 to it fails with cannot use f (variable of type float64) as Celsius value, and the same holds between int and int64. Go never converts numbers for you, because a silent widening or truncation is a real class of bug.
That sounds punishing until you notice literals are exempt. An untyped constant has no type until it meets a context, so it adopts whatever is required:
var f float64 = 3 // the literal becomes a float64
var c Celsius = 20.5 // the same literal rule, a different type
// var bad Celsius = f // error: cannot use f (variable of type float64)
fmt.Println(-7/2, -7%2) // -3 -1: division truncates toward zero
Constant arithmetic is exact and unbounded; only assigning to a variable can overflow. Two more arithmetic facts: signed overflow wraps silently rather than panicking, so an int8 holding 127, incremented, is -128; and comparability is per type — structs and arrays support ==, while slices, maps and funcs may only be compared against nil.
Rule: when the compiler demands a conversion, it has found two things you were treating as one — name which is which rather than reflexively adding T(x).
One loop, a switch that stops, and a range that copies
~2 min
for is the only loop keyword, and switch breaks at the end of every case. The one to internalize is range: it hands you a copy of each element, so writing to the loop variable changes nothing.
for with three clauses is the counted loop, for cond is the while loop, and bare for is the infinite one. Since Go 1.22, for i := range 5 counts 0 to 4.
if takes an optional initializer, and this is the shape most Go is written in: if v, err := f(); err != nil scopes both names to the if and its else, keeping the successful path unindented.
switch does not fall through — every case breaks implicitly, and fallthrough is an explicit statement you almost never want. A switch with no subject (switch { case x > 10: ... }) replaces an if/else ladder.
The trap is range. The value variable is a copy of the element, so the loop below leaves the slice untouched:
s := []int{1, 2, 3}
for _, v := range s {
v *= 10 // modifies the copy
}
// s is still [1 2 3]; use s[i] *= 10 to change it
Ranging a map is deliberately unordered, and the order genuinely varies between runs of the same binary — that is randomization on purpose, to stop anyone depending on it. Ranging a string decodes it: the index jumps by each character's byte width and the value is a rune, not a byte.
Rule: if a range loop appears to do nothing, you assigned to the copy — index the collection instead.
Everything is copied, and a pointer is how you opt out
~2 min
Arguments are always copied, so a function cannot change what you passed unless you pass a pointer. Pointers have no arithmetic and cannot dangle: returning the address of a local is safe, because the compiler decides what lives on the heap.
Functions return tuples, and the (value, error) pair is the shape the whole language is built around. Named results are pre-declared and zeroed, and a deferred closure can rewrite them.
Every argument is copied. Passing a pointer copies the pointer, which is precisely how you let a function reach the original. new(T) gives you a zeroed *T; make is different in kind — it builds and initializes a slice, map, or channel, and applies to nothing else.
Pointers are deliberately dull: no arithmetic, no offsetting, and p.Field dereferences without any (*p) ceremony. What they cannot do is dangle. Returning the address of a local is not just legal, it is idiomatic:
func NewCounter() *Counter {
c := Counter{limit: 10}
return &c // safe: c escapes to the heap
}
The compiler's escape analysis decides placement, so there is no keyword for stack versus heap and no bug class where a pointer outlives its target.
Closures capture the variable, not its value at capture time, so a later write is visible inside. That used to make loops treacherous, because every closure shared one loop variable; since Go 1.22 each iteration gets a fresh one, and the classic "all three print 3" surprise is gone. Code written for older versions still carries the workaround.
Rule: decide whether a function needs to observe or to change its argument — that choice, not performance, is what picks a pointer.
Slices, maps and strings: what copying one actually copies
~2 min
Arrays and structs copy in full. Slices, maps and strings are descriptors, so copying one copies a header and both copies see the same data. That single distinction explains the aliasing surprises, the nil-map panic, and why a string's length is not its character count.
An array's length is part of its type, and assigning one copies every element. Slices are the opposite: a slice value is a pointer, a length and a capacity — a view onto a backing array it does not own.
So two slices can share storage, and append decides silently whether to reuse it:
a := []int{1, 2, 3, 4, 5}
b := a[1:3] // len 2, cap 4 — room to spare
b = append(b, 777) // fits, so it writes into a's array
fmt.Println(a) // [1 2 3 777 5] — a[3] was overwritten
Append reuses the backing array while capacity allows and copies to a fresh one when it does not, so whether the parent changes depends on spare capacity, not on anything visible at the call site. The three-index form a[1:3:3] caps capacity, forcing the next append to copy. A nil slice, meanwhile, is fully usable: you can range it, take its length, and append to it. It differs from []int{} in two places — == nil, and JSON, where it encodes as null rather than [].
Maps share the same copy-the-header behavior, plus one asymmetry: reading or ranging a nil map is legal and empty, while writing to one panics. Use the comma-ok form (v, ok := m[k]) to tell an absent key from a stored zero. Map elements are not addressable, so m["a"].Field = 1 does not compile.
A string is an immutable sequence of bytes, not characters. len("héllo") is 6, and indexing yields a byte.
Rule: before mutating through a slice or map, ask who else holds a header onto the same data.
Composition instead of inheritance
~2 min
Embedding promotes an inner type's fields and methods, which looks like inheritance and is not: there is no subtype relationship, so an outer type is never usable where the inner one is required. Receivers decide mutation, and pointer receivers decide what satisfies an interface.
Embed a type by writing it in a struct with no field name, and its fields and methods are promoted to the outer type. What you do not get is subtyping. A Car embedding an Engine answers c.Start() and c.HP, but is not an Engine — pass one where an Engine is wanted and you must name the field, c.Engine. Promotion is delegation with less typing, not an "is-a" claim.
Methods attach to any type your package defines, not just structs — a named slice or func type carries them too. You cannot add one to another package's type; define a local type first.
A value receiver gets a copy, so it cannot change the caller's value; a pointer receiver can.
That choice leaks into interfaces, which is where the mistake shows up. *T's method set holds both value and pointer methods; T's holds only the value ones. So a type whose method has a pointer receiver is not satisfied by the value — the compiler says P does not implement Stringer (method String has pointer receiver), and passing &p fixes it. Go takes the address for you when calling directly, but only on something addressable — a map element is not.
Rule: pick one receiver kind per type and keep it — mixing them is where method-set errors come from.
Interfaces are satisfied by accident, and generics are the other tool
~2 min
There is no implements clause: having the methods is the whole requirement, so interfaces belong to the code that consumes them. An interface value is a type-and-value pair, which produces the one trap everyone hits — an interface holding a nil pointer is not nil.
A type satisfies an interface by having its methods. Nothing is declared. That inverts where interfaces live: the consumer declares the small interface it needs, and the provider returns a concrete type. io.Reader — one method — is the model.
An interface variable stores a dynamic type alongside a value, which is why this trap bites:
var p *MyErr = nil // a nil pointer...
var err error = p // ...stored in an interface
fmt.Println(err == nil) // false: the interface still holds a type
An interface is nil only when it holds no type at all, so a function declared to return error that returns a nil concrete pointer hands back a non-nil error. Printing it does not help: depending on whether that type's Error method survives a nil receiver you get <nil> or an ordinary-looking message. Return a literal nil.
Use the two-result assertion (v, ok := x.(T)) — the single-result form panics — or a type switch for several types.
Generics answer a different question. A type parameter carries a relationship between types, and a constraint is an interface used as a type set. ~int also admits named types whose underlying type is int, where a bare int does not; the compiler even names the fix, possibly missing ~ for float64. You may only call what the constraint declares.
Rule: if the code only calls methods, an interface is simpler — reach for a type parameter when a type must be carried through to the result.
Errors are values, and defer is not a finally block
~2 min
There is no throw. An error is an ordinary return value checked immediately, and wrapping with %w is what keeps it inspectable. defer runs on every exit path including a panic — but its arguments are captured when the defer executes, not when it runs.
error is one method, Error() string. Errors travel as return values, checked right where they happen, which is why Go code is a column of if err != nil — verbose by design, because every failure point is visible.
Add context by wrapping. fmt.Errorf("loading config: %w", err) keeps the original reachable, so errors.Is(err, ErrNotFound) still matches through any number of layers; %v produces the same message but flattens the chain and breaks that. Use errors.Is to compare against a sentinel and errors.As to pull out a concrete type.
defer runs when the surrounding function returns, latest first, including while a panic unwinds — which is what makes it the unlock-and-close mechanism. Two things catch people. Arguments are evaluated at the defer statement, not at the call. And a bare defer f.Close() silently throws away the error:
w := bufio.NewWriter(f)
fmt.Fprint(w, "important data")
return nil // never flushed: reports success, writes 0 bytes
That really does return a nil error and leave an empty file. Return w.Flush(), and capture the close error into a named result.
Panic is for bugs the program cannot continue past, not for control flow. Unrecovered, it unwinds every defer, prints a trace and exits with status 2. recover only works called directly from a deferred function.
Rule: wrap with %w when you add context, and compare with errors.Is rather than matching message text.
Goroutines are cheap; channels are what makes them safe
~2 min
go f() starts a concurrent call and gives you nothing back — no handle, no identifier, no way to stop it. A goroutine ends only by returning, so every one you start needs a reason it will finish. Channels both pass values and order the two sides.
A goroutine costs little more than a small, growable stack, so thousands are ordinary. But go f() returns nothing: there is no handle, no identifier and no kill. A goroutine ends when its function returns, and cancellation is cooperative — you signal, and it must choose to look.
Two consequences follow. When main returns the process exits without waiting, so a result you never waited for may never appear. And the assumption that a stuck goroutine eventually gets cleaned up is wrong — a blocked one is not garbage:
func leak() {
ch := make(chan int) // nobody ever sends
go func() { <-ch }() // blocked forever
}
// 100 calls later, after a collection: runtime.NumGoroutine() is 101
Channels are the ordering mechanism. An unbuffered channel is a rendezvous — the send completes only when a receiver takes the value, so both sides know where the other is. A buffered one blocks only when full.
The rules around closing are where the panics live. Receiving from a closed channel returns the zero value immediately and never blocks, and the second result of v, ok := <-ch is what separates "closed" from "someone sent a zero". Sending on a closed channel panics, and so does closing one twice — which is why closing is the sender's job. Closing wakes every receiver at once, and that broadcast is the standard shutdown signal.
select waits on several operations; among those ready it picks uniformly at random, a default case makes it non-blocking, and a nil channel disables a case permanently.
Rule: when you write go, say out loud how that goroutine ends — if you cannot, you have written a leak.
Shared memory, and why "it worked" proves nothing
~2 min
A data race is two goroutines touching one location with at least one write and no ordering between them. It does not reliably misbehave — racy code often prints the right answer — so correctness comes from the tools, not from observation. context carries cancellation down a call tree.
Visibility between goroutines is defined by synchronization, not by wall-clock order. Without an ordering edge — a channel operation, a mutex, sync.Once — one goroutine's write is simply not guaranteed to be seen by another.
The trap is that races hide. A loop of 100 goroutines incrementing a shared counter without a lock printed the correct 100 in four of twelve runs on one machine, and 90, 96, 97, 98 and 99 on the others. Running it and liking the answer proves nothing; go test -race reports races that actually happened, so it demonstrates presence rather than absence.
A mutex guards an invariant over data, not a line of code: every access to that data must take the same lock, and defer mu.Unlock() covers every return path.
mu.Lock()
defer mu.Unlock()
total += n
count++ // both fields, one lock
Never copy a value containing a mutex — go vet reports passes lock by value. Concurrent writes to a plain map are detected by the runtime and kill the process outright with fatal error: concurrent map writes; recover cannot catch it.
context handles cancellation. It is the first parameter, never stored in a struct. The cancel function must be called or the context leaks — go vet flags a discarded one. Cancellation flows strictly downward: canceling a parent cancels every derived context, and never the reverse.
Rule: run your tests with -race in continuous integration — a race you cannot reproduce is still shipping.
The toolchain, and the test culture that comes with it
~2 min
Builds are reproducible without a lock file, because the resolver picks the highest version something actually requires rather than the newest published. Tests need no framework: a table of cases run through t.Run, compared with if got != want, is the whole convention.
A module is declared by go.mod: a module path that prefixes every import inside it, a go directive, and a list of requirements. Version selection differs most from other ecosystems — the resolver walks the requirement graph and takes the highest version any module asks for, instead of the newest one published. Because nothing floats, the result is reproducible without a lock file; go list -m all prints it. A major version past v1 carries /v2 in the module path, so two majors can coexist in one build. go.sum pins content hashes, and go mod tidy reconciles requirements with imports.
A //go:build line selects a file per operating system, which is how one tree cross-compiles.
Testing is deliberately unfurnished. Tests live in _test.go files as func TestXxx(t *testing.T), and there is no assertion library — you compare and report, got before want:
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := Abs(c.in); got != c.want {
t.Errorf("Abs(%d) = %d; want %d", c.in, got, c.want)
}
})
}
t.Run names each case, so a failure reads --- FAIL: TestAbs/negative and -run TestAbs/negative reruns just that one. Errorf records and continues; Fatalf stops that test immediately.
Rule: write the table first — once cases are data, adding the one that just broke costs a line.
Standard-library contracts worth knowing before you hit them
~2 min
A few library contracts are unusual enough to cause bugs that survive testing. The sharpest: Read may return bytes andio.EOF in the same call, so a loop that checks the error before using the count can silently drop the last chunk.
io.Reader is one method, and its contract is looser than it looks. A Read that hits the end of input may return the bytes it read together with io.EOF, or return them with a nil error and report io.EOF on the next call. Both are legal, so the caller must process n bytes before considering the error.
n, err := r.Read(buf)
out = append(out, buf[:n]...) // first
if err != nil { return out } // then
Reverse those two lines and the bug is invisible against strings.Reader, which reports the end separately — and drops the final chunk against a reader that does not. Prefer io.Copy or io.ReadAll, which get this right.
bufio.Writer holds bytes until you Flush; skip it and the data never arrives. In encoding/json, only exported fields are encoded — an unexported one is silently skipped, and so is a misspelled tag key, which falls back to the Go field name. Decoding into any turns every number into a float64, so a large integer loses precision without an error.
Two more: time formats are written as the reference instant 2006-01-02 15:04:05 rather than as placeholder codes, and a Duration is a typed count of nanoseconds — multiply by time.Second, never pass a bare number. And http.DefaultClient has a zero Timeout, which the documentation defines as no timeout at all.
Rule: when a standard type seems to behave oddly, read its doc comment — these contracts are stated there and almost nowhere else.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.