Go is a statically typed, compiled language with garbage collection, created at Google in 2007 by Robert Griesemer, Rob Pike and Ken Thompson to build large networked servers with fast builds and simple, readable code.
History: open source since 2009, version 1.0 in 2012, a new release every February and August.
Strengths: compiles in seconds to a single static binary; goroutines and channels put concurrency into the language; a small spec that the whole team reads the same way; a strong standard library (net/http, encoding/json, testing); gofmt, go test and go vet out of the box; the Go 1 compatibility promise keeps old code building.
Weaknesses: verbose error handling (if err != nil); no sum types or real enums; generics arrived only in 1.18, and generic methods only in 1.27; a garbage collector gives less control over memory and latency than C++ or Rust; nil interfaces and shared slice arrays surprise newcomers.
Where it fits: backend services, CLIs and infrastructure: Docker, Kubernetes, Terraform and Prometheus are written in Go. It is a poor fit for GUIs, hard real-time systems and heavy numeric work.
GoJunior
Go Basics
Syntax, zero values, constants and iota, control flow, defer and closures
Questions closed
0 / 25
- 0Review
- 13Asked often
Questions
Go leaves out features that add a second way to write the same thing or hide control flow, and each has one plain replacement. That is also the honest answer to "is Go object-oriented": it has types with methods and interfaces, but no classes and no inheritance.
**Ternary ?:**: an if/else; for the most common case, the built-ins min and max (Go 1.21).
**while, do...while**: for cond {}, and for {} with break.
Inheritance: struct embedding for reuse, and interfaces, satisfied implicitly, for polymorphism.
Exceptions: errors as ordinary return values checked with if err != nil; panic is for bugs and impossible states.
Overloading and default arguments: different names (strings.Split, strings.SplitN), variadic parameters or an options struct.
Implicit conversions: an explicit T(x) for every conversion.
The cost is more lines in places; the gain is code that every Go developer reads the same way.
A package is all the .go files in one directory that share the same package clause. You import it by its path (the module path from go.mod plus the directory) and only names that start with an uppercase letter are visible outside it.
Privacy belongs to the package, not the file: files of one package see each other's lowercase names without any import.
The rule covers struct fields and methods too, which is why encoding/json ignores lowercase fields.
A package under an internal/ directory can be imported only by code inside the tree rooted at that directory's parent; this is the way to share code within a module without making it public API.
package main with a func main() builds an executable; any other package is a library.
An unused import is a compile error, and two packages may not import each other.
All three compile the same way and differ only in where the binary goes: go run builds it in a temporary directory, runs it and throws it away; go build writes it to the current directory (or to -o); go install puts it in $GOBIN, by default ~/go/bin.
On a library package go build writes no file: it only compiles and type-checks, which is why CI runs go build ./....
All three share the build cache (go env GOCACHE), so a rebuild only recompiles what changed.
Cross-compiling takes two variables: GOOS=linux GOARCH=arm64 go build. Adding CGO_ENABLED=0 gives a fully static binary that runs in scratch or distroless images.
go run is for development. Ship the output of go build, so the container needs no toolchain and does not compile on every start.
var declares a variable anywhere, with a type, an initial value or both; the short form := declares and infers the type in one step but works only inside a function body. At package level every declaration starts with a keyword: var, const, type or func.
var n int gives the zero value, var n = 5 infers int, var n int64 = 5 states the type.
:= needs at least one new name on the left: in data, err := f() with err already declared in the same scope, data is declared and err is only assigned.
Use var when there is no value yet (var rows []Row) or when the literal would infer the wrong type (var id uint64 = 7).
An unused local variable is a compile error, declared and not used; an unused package-level variable is not.
:= inside an inner block declares a new variable that hides the outer one. That is shadowing, a common source of bugs.
A zero value is what a variable holds when it is declared without an initialiser: 0 for numbers, false for bool, "" for strings, nil for pointers, slices, maps, channels, functions and interfaces. Structs and arrays get the zero value of every field or element.
nil slice: len is 0, and append and range work; there is no need to make it first.
nil map: reads return the value type's zero and delete does nothing, but a write panics with assignment to entry in nil map.
nil channel: send and receive block forever, which select uses to switch a case off; close panics.
nil pointer: dereferencing it panics with invalid memory address or nil pointer dereference.
Go has no uninitialised memory, so design types whose zero value is ready to use, like sync.Mutex, bytes.Buffer and strings.Builder, which need no constructor.
A constant is a value fixed at compile time (a number, string, boolean or rune) declared with const. iota is a counter inside a const block that starts at 0 and grows by one per line, so a Go enum is a named type plus a block of iota constants.
A line with no expression repeats the previous one with the next iota, so adding a value means adding a name. _ skips a value, and expressions work: KB = 1 << (10 * iota).
An untyped constant (const timeout = 30) has no type until it is used, so timeout * time.Second compiles as a time.Duration. With var timeout = 30 the same line fails: mismatched types int and time.Duration.
A typed constant (const timeout int = 30) is checked like a variable of that type.
Go enums are not closed: State(42) compiles. Validate values that come from outside, and add a String() method or generate one with stringer.
Put an honest default such as StateUnknown at iota == 0, because that is also the zero value of the type.
int and uint are 64 bits on 64-bit platforms and 32 bits on 32-bit ones. Integer overflow silently wraps around at run time, integer division by zero panics, and floating-point division by zero gives +Inf, -Inf or NaN.
The sized types are int8 to int64, uint8 to uint64, float32 and float64; byte is an alias for uint8 and rune for int32.
Overflow wraps in two's complement with no error: an int8 holding 127 plus one is -128. A constant that does not fit is caught at compile time instead: var b int8 = 128 fails with (overflows).
uint has no negative values, so uint(0) - 1 wraps to the maximum value; prefer int unless you need bit patterns or the full unsigned range.
Integer division truncates toward zero: -7/2 is -3 and -7%2 is -1.
Dividing by a variable that holds 0 panics with integer divide by zero; dividing by a constant 0 does not compile. Check float results with math.IsInf and math.IsNaN.
Where overflow matters, such as money or counters from user input, check against math.MaxInt64 before the operation or use math/big.
Every conversion is written out as T(x): Go never converts between numeric types for you, not even int to int64, because silent conversions are a classic source of overflow and precision bugs. Mixing types is a compile error such as mismatched types int64 and int.
A conversion between numeric types always compiles and can lose data quietly: with v := 300, int8(v) is 44, and with f := 3.9, int(f) is 3; that is truncation, not rounding (use math.Round).
Convert before dividing. With done, total := 3, 4, float64(done/total) is 0 because integer division already dropped the fraction; float64(done) / float64(total) is 0.75.
string(rune(65)) is "A", not "65", and go vet flags string(i) on an integer. Numbers and text convert through strconv.Itoa and strconv.Atoi, which returns an error for input like "42x".
Untyped constants are the exception: 2 * time.Second works because the constant takes the type it is used with.
for is Go's only loop keyword, in three shapes: the three-clause for i := 0; i < n; i++, the condition-only for cond {} that plays the role of while, and the bare for {} that runs until break or return. for ... range iterates over values.
slice, array: the index and a copy of the element; changing the copy does not change the slice, so write s[i].
string: the byte offset and a rune: range decodes UTF-8, so offsets skip over multi-byte characters.
map: key and value, in a deliberately random order.
channel: values until the channel is closed.
int (Go 1.22): for i := range 10 counts from 0 to 9.
function (Go 1.23): an iterator such as iter.Seq[K], for example for k := range maps.Keys(m).
The range expression is evaluated once, before the first iteration. There is no do...while.
It prints run a, stopping, run b. break leaves the innermost for, switch or select, so here it only leaves the switch, and the loop moves on to "b".
Label the loop and break out of the label: Jobs: for ... and break Jobs. continue Jobs works the same way; a label is the only way to target an enclosing statement.
Or move the loop into its own function and return from it, which often reads better than a label.
A Go case never falls through on its own: every case ends with an implicit break, which is why the explicit one changed nothing. fallthrough must be the last statement of a case and enters the next case body without testing it.
One case can list several values, case "a", "b":, and a switch with no expression means switch true, the idiomatic replacement for a long if/else if chain.
The same trap applies to break inside a select in a loop.
A function can return several values, and by convention the last one is an error. The blank identifier _ is a write-only placeholder: it takes a value you are forced to receive but do not need.
The caller takes all results or none: n := strconv.Atoi(s) fails with assignment mismatch: 1 variable but strconv.Atoi returns 2 values.
_ discards results and loop values: _, err := w.Write(b), for _, v := range s.
import _ "github.com/jackc/pgx/v5/stdlib" imports a package only for its init() side effect, here registering a database/sql driver.
var _ io.Writer = (*Buffer)(nil) is a compile-time check that a type implements an interface.
Assigning an error to _ is legal but a code smell, and linters such as errcheck report it.
A variadic function takes any number of arguments of one type through its last parameter, as in func sum(xs ...int) int; inside the function xs is a plain []int. To pass an existing slice, spread it: sum(nums...).
Only the last parameter can be variadic. Calling sum() with no arguments passes a nil slice, which len and range handle fine.
sum(nums...) passes the slice itself, not a copy, so if the function writes to xs[i] the caller sees it. Listed arguments, sum(1, 2, 3), get a fresh slice.
The two forms cannot be mixed: sum(1, nums...) does not compile; build one slice with append([]int{1}, nums...).
A []string cannot be spread into ...any: fmt.Println(strs...) fails with cannot use strs (variable of type []string) as []any value. Copy the values into a []any first.
Go always passes by value: the function gets a copy of each argument. To let a function change the caller's variable, or to avoid copying a large struct, pass a pointer; the pointer itself is copied, but both copies point at the same data.
The copy is shallow. A slice, map or channel refers to its data, so the callee can change slice elements or map entries, but an append that reallocates or a reassignment of the parameter stays local.
Use a pointer when the function must modify the value, when the struct is large, or when the type must not be copied at all (anything containing a sync.Mutex).
Use a value for small read-only data like time.Time or a two-field struct: copying is cheap and values can stay on the stack, while pointers often push data to the heap.
&x takes an address, *p dereferences, and p.Field dereferences automatically. There is no pointer arithmetic outside unsafe.
Returning a pointer to a local variable is safe: escape analysis moves the variable to the heap.
new(T) works for any type: it allocates a zeroed T and returns a *T. make works only for slices, maps and channels: it initialises their internal structure and returns the value itself, not a pointer.
Slices, maps and channels need make because their zero value is nil: make(map[string]int) can be written to, make([]int, 0, 100) sets length and capacity, make(chan int, 10) sets the buffer size.
new(map[string]int) compiles but returns a pointer to a nil map, and writing through it still panics.
new is rare in practice: &User{Name: "ada"} allocates and sets fields in one step.
Since Go 1.26 new also accepts an expression: new(int64(30)) returns a *int64 holding 30, which is handy for optional pointer fields.
Neither decides whether the memory lives on the heap or the stack; escape analysis does.
defer schedules a call to run when the surrounding function returns, whether by return or while a panic unwinds. Deferred calls run in last-in, first-out order, and their arguments are evaluated immediately, at the defer statement.
The usual pattern releases a resource right after acquiring it: defer f.Close(), defer mu.Unlock(). Every return path then cleans up.
Because arguments are evaluated early, defer fmt.Println("took", time.Since(start)) prints almost zero, and go vet flags that line. Defer a closure, defer func() { ... }(), to read variables at exit.
defer belongs to the function, not the block: in a loop that opens 10 000 files they all stay open until the function returns (too many open files). Move the loop body into its own function.
A deferred closure can change named results, which is how recover() turns a panic into an error.
os.Exit and log.Fatal end the process without running deferred calls.
Since Go 1.14 a defer outside a loop is compiled inline and costs almost nothing.
fmt.Println prints its arguments separated by spaces and adds a newline; fmt.Printf formats them with a template of verbs; fmt.Sprintf formats the same way but returns the string instead of printing it. Fprintf writes to any io.Writer, and Errorf builds an error.
%v is the default format, %+v adds struct field names, %#v prints Go syntax, and %T prints the type.
%q prints a quoted, escaped string, so an empty value or a trailing space stays visible in a log.
Other common verbs: %d integer, %s string, %x hex, %.2f two decimals, %5d width 5, %% a literal percent sign.
A type with a String() string method is printed through it by %v and %s, which is why a time.Duration prints as 1.5s.
A wrong verb or argument count does not panic: the output gets %!d(string=ada) or %!v(MISSING). go vet catches both, and go test runs it automatically.
The := in the if header declares a new resp and a new err scoped to that if, so the outer err is never assigned and the function returns its zero value, nil. The compiler is satisfied because every variable is used.
Every if, for and switch header and every {} block opens a new scope, and := always declares in the current one. The "at least one new variable" rule only lets := reuse names declared in that same scope.
Shadowing is legal Go, and the default go vet checks leave it out because of false positives. Run the shadow analyser on purpose (go vet -vettool=$(which shadow)) or enable shadow for govet in golangci-lint.
The same bug appears with ctx re-declared in a block, and with a variable that hides a package, like url := ... hiding net/url.
The fix: handle the error in the scope where it appears, and give a value that must outlive the block its own name, like lastErr.
i is a uint, so i >= uint(len(xs))-n is always true when n == len(xs) (the right side is 0); after i reaches 0, i-- wraps around to 18446744073709551615 and xs[i] panics.
Why: unsigned integers never go below zero: 0 - 1 wraps to the maximum value instead of -1, with no error. A loop condition i >= 0 on an unsigned i is therefore always true.
A silent variant: with n > len(xs) the expression uint(len(xs))-n wraps to a huge number, the condition is false at once, and the function quietly returns nothing.
The fix: use int for indexes and counts, clamp n with min(n, len(xs)), and the loop for i := len(xs) - 1; i >= len(xs)-n; i-- ends correctly at -1 when n == len(xs). Go itself uses int for len and indexes for this reason.
Catch it: staticcheck reports SA4003 (comparing an unsigned value with >= 0 is always true). A test with n == len(xs) and one with n > len(xs) catches both cases.
It returns 6: return n + 2 assigns 3 to the named result n, then the deferred closure doubles it before the caller gets n. With an unnamed result it would return 3, because the value is copied out before the defer runs and the defer only changes a local variable.
Named results are ordinary variables: zeroed at function entry, assigned by return, and still visible to the deferred calls that run after it.
That makes them the one way to change what a function returns after return: reporting a Close error (if cerr := f.Close(); err == nil { err = cerr }), turning a panic into an error with recover(), or wrapping every returned error in one place.
A bare return returns whatever the named results hold at that moment. If a block re-declares err with :=, a bare return inside that block does not compile (result parameter err not in scope at return), but one after the block compiles and returns the outer, never-assigned values.
Name results to document a pair of the same type, like (lat, lng float64), or to give a defer something to write to, not to save typing at return.
defer f.Close() runs when hashAll returns, not at the end of each iteration, so every file stays open until the whole loop finishes; with enough paths the process runs out of file descriptors and os.Open fails.
Why: defer belongs to the surrounding function, not to a block or a loop body. Each iteration adds one more call to the function's defer list, so N files mean N open descriptors at once and N pending calls in memory.
Why tests pass: the descriptor limit is usually in the thousands, and since Go 1.19 a Go process raises its soft limit to the hard limit at startup, so a test with ten files never hits it.
The fix: move the per-file work into its own function, hashFile, so its defer runs as soon as each file is done and at most one file is open at a time. A closure called inside the loop, func() error { ... }(), does the same.
Catch it: revive's defer rule (in golangci-lint) reports prefer not to defer inside loops; a test run under a lowered ulimit -Hn with more files than the limit reproduces the failure.
A closure is a function literal that uses variables of the enclosing function; it captures the variables themselves, not their values. Before Go 1.22 all iterations of a for loop shared one loop variable, so closures saw the value it had when the loop ended; since 1.22 each iteration gets its own.
Captured variables live as long as the closure, even after the outer function returns; the compiler moves them to the heap. That is how a counter generator keeps its state.
Before 1.22, for _, v := range items { go func() { use(v) }() } often had every goroutine see the last item, and every &v was the same pointer. The fix was v := v at the top of the body.
The new rule covers three-clause loops too, and it is switched on by the go line in go.mod, not by the toolchain: a module that says go 1.21 keeps the old behaviour even when built with Go 1.25.
On 1.22 and later the v := v copy is dead code; the copyloopvar linter finds it.
Unchanged: a closure over a variable declared outside the loop still shares it with every iteration, so concurrent writes to it are still a data race.
init() runs once per package, after all imported packages and the package's own variables are initialised and before main. Real work does not belong there: it cannot return an error, runs on every import (tests included) and hides global state.
Full order: imported packages first, depth-first, each exactly once; then package-level variables in dependency order, not source order; then every init() in the order the files reach the compiler (the go tool sorts them by name), top to bottom within a file; then main.main.
A package may have several init functions, even several in one file.
Packages that do not import each other are initialised in import-path order (specified since Go 1.21), so code that relies on another package's init side effect breaks when a path is renamed or an import is added.
Why to avoid real work there: init runs on every import, including in every test binary; it cannot return an error, only panic or log.Fatal; it runs before flags are parsed; and it hides global state.
Fine uses: registering a driver or codec (import _ "image/png"), building a lookup table, checking constants. Anything that reads config, opens connections or can fail belongs in main, passed down through constructors.
type UserID string is a type definition: a new, distinct type with string as its underlying type and no methods of its own. type UserID = string is an alias: a second name for string, interchangeable with it everywhere.
A defined type needs an explicit conversion, UserID(s), so the compiler stops you from passing an email where a user id is expected. Untyped constants still assign directly: var id UserID = "u_1".
A defined type can have its own methods, and it does not inherit the methods of the type it was defined from: type Stamp time.Time has no Format.
An alias brings no new type, so you cannot attach methods to an alias of another package's type, and %T prints the original name.
Aliases exist for refactoring: a type moves to another package while the old name keeps compiling. byte, rune and any are aliases of uint8, int32 and interface{}.
Generic aliases such as type Set[T comparable] = map[T]struct{} are fully supported since Go 1.24.
For everyday code the big ones are per-iteration loop variables and range over integers (1.22), iterators (1.23), generic aliases and Swiss-table maps (1.24), container-aware GOMAXPROCS (1.25), new(expr) plus the Green Tea GC by default (1.26), and generic methods (1.27).
Language
1.22: each loop iteration gets its own variable, gated by the go line in go.mod; for i := range n works on integers.
1.23: range over functions of the form func(yield func(T) bool), the iter package, and iterator helpers such as slices.Collect(maps.Keys(m)).
1.24: generic type aliases.
1.26: new accepts an expression: new(int64(30)).
1.27: methods can declare their own type parameters (generic methods); interface methods still cannot.
Standard library and tools
1.22: http.ServeMux patterns with methods and wildcards, GET /items/{id}, read with r.PathValue("id").
1.23: with go 1.23 or later in go.mod, timers that nobody references are garbage-collected even without Stop, so time.After in a loop no longer leaks.
1.24: testing.B.Loop for benchmarks, os.Root for file access confined to one directory, the weak package, and tool directives in go.mod.
1.25: sync.WaitGroup.Go, testing/synctest for testing timer-based code on a fake clock, and encoding/json/v2 as an experiment.
1.27: encoding/json is now backed by the v2 implementation, with the new encoding/json/jsontext package; asynctimerchan is removed, so timer channels are always unbuffered.
Runtime
1.24: maps are Swiss tables, faster on large maps.
1.25: GOMAXPROCS follows the container's CPU limit and updates when it changes.
1.26: the Green Tea GC, an experiment in 1.25, is on by default and cuts GC CPU overhead for allocation-heavy services.
1.27: the goroutineleak profile is generally available in runtime/pprof.