Go was designed at Google in 2007 by Griesemer, Pike, and Thompson — people who were frustrated with C++ compile times and Java complexity. It compiles to native binaries, has a garbage collector, and deliberately has fewer features than you expect.
Core ethos: Simplicity over cleverness. Explicit over implicit. One way to do things. If you're fighting the language, you're probably holding it wrong.
| Feature | Go's stance |
|---|---|
| Generics | Added in 1.18 — minimal, not like C++ templates |
| Inheritance | Doesn't exist. Use composition + interfaces. |
| Exceptions | Doesn't exist. Errors are values, returned explicitly. |
| Operator overloading | Doesn't exist. Intentionally. |
| Ternary operator | Doesn't exist. Write the if. |
| Macros / preprocessor | Doesn't exist. |
| Function overloading | Doesn't exist. |
| Classes | Doesn't exist. Structs + methods instead. |
| Concurrency | First-class via goroutines + channels (CSP model) |
| GC | Yes — low-latency, concurrent mark-and-sweep |
| Compile speed | Extremely fast — seconds even for large projects |
| Binaries | Single static binary, no runtime deps to ship |
Go is statically typed, compiled, and uses C-family syntax — you'll feel at home immediately, but a few things will surprise you.
package main // every file declares a package; "main" is the entry package
import (
"fmt"
"math"
)
func main() {
// := is declare+assign; type is inferred
x := 42
name := "gopher"
pi := math.Pi
// explicit type declaration
var count int = 0
fmt.Println(x, name, pi, count)
// for is the ONLY loop — no while, no do-while
for i := 0; i < 5; i++ {
fmt.Println(i)
}
// "while" in Go
n := 10
for n > 0 { n-- }
// infinite loop
for {
break
}
}
Unused variables and unused imports are compile errors, not warnings. Go enforces this to keep code clean. The blank identifier _ discards values you don't need.
This is Go's answer to out-parameters, tuples, and exceptions — functions return multiple values directly:
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("division by zero")
}
return a / b, nil
}
result, err := divide(10, 3)
if err != nil {
log.Fatal(err)
}
func readFile(path string) {
f, err := os.Open(path)
if err != nil { log.Fatal(err) }
defer f.Close() // runs when function returns, regardless of how
```
// ... do stuff with f
```
}
// defers stack LIFO — useful for multiple cleanups
No classes. Structs carry data; methods are defined separately and attached to a type — any type.
type Point struct {
X, Y float64
}
// method on Point — note the receiver (p Point)
func (p Point) Distance() float64 {
return math.Sqrt(p.X*p.X + p.Y*p.Y)
}
// pointer receiver — to mutate the struct
func (p *Point) Scale(factor float64) {
p.X *= factor
p.Y *= factor
}
// Composition — embed structs (not inheritance!)
type Circle struct {
Point // embedded — Circle "promotes" Point's fields + methods
Radius float64
}
c := Circle{Point{3, 4}, 5}
fmt.Println(c.Distance()) // promoted from Point
fmt.Println(c.X) // promoted field
Arrays are fixed-size and rarely used directly. Slices are the go-to sequence type — a view over an underlying array.
// slice literal
nums := []int{1, 2, 3}
nums = append(nums, 4, 5)
// make with length + capacity (like ArrayList preallocate)
buf := make([]byte, 0, 1024)
// range loop — like Python enumerate / Java for-each
for i, v := range nums {
fmt.Printf("%d: %d\n", i, v)
}
// slicing (half-open, like Python)
sub := nums[1:3] // [2 3] — shares the same backing array
Slices are reference types. A sub-slice shares memory with its parent. Modifying one affects the other. Use copy() when you need independence.
scores := make(map[string]int)
scores["alice"] = 99
// check existence — the two-value form
val, ok := scores[“bob”]
if !ok { fmt.Println(“not found”) }
// delete
delete(scores, “alice”)
x := 42
p := &x // *int pointer
*p = 100 // dereference
// No pointer arithmetic. No void*. GC manages lifetime.
// new() allocates zero-value heap memory, returns pointer
pp := new(int) // *int pointing to 0
Functions are values — pass them, return them, store them. Closures capture by reference.
// function type
type Transformer func(int) int
func apply(nums []int, fn Transformer) []int {
result := make([]int, len(nums))
for i, v := range nums {
result[i] = fn(v)
}
return result
}
doubled := apply([]int{1,2,3}, func(x int) int { return x * 2 })
// closure — counter factory
func makeCounter() func() int {
n := 0
return func() int {
n++
return n
}
}
next := makeCounter()
next() // 1
next() // 2
func sum(nums ...int) int {
total := 0
for _, n := range nums { total += n }
return total
}
sum(1, 2, 3)
s := []int{1, 2, 3}
sum(s...) // spread a slice into variadic — like JS spread
This is Go's killer feature. Goroutines are multiplexed over OS threads by the Go runtime — you can spin up millions of them. They're not OS threads, not green threads — they're M:N scheduled coroutines.
func work(id int) {
fmt.Printf("worker %d done\n", id)
}
// launch a goroutine — just add "go"
go work(1)
go work(2)
// goroutine with closure
go func() {
fmt.Println("anonymous goroutine")
}()
// sync with WaitGroup (like CountDownLatch in Java)
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
work(id)
}(i) // pass i as arg — avoid closure-over-loop-variable bug
}
wg.Wait()
Classic bug: closing over a loop variable in a goroutine. By the time the goroutine runs, the loop may have advanced. Always pass i as an argument to the goroutine function.
type SafeMap struct {
mu sync.Mutex
m map[string]int
}
func (s *SafeMap) Set(key string, val int) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[key] = val
}
Go's mantra: "Don't communicate by sharing memory — share memory by communicating." Channels are typed conduits between goroutines.
// unbuffered channel — synchronous rendezvous
ch := make(chan int)
go func() { ch <- 42 }() // send blocks until receiver is ready
val := <-ch // receive
// buffered — send doesn't block until buffer full
bch := make(chan string, 10)
// close signals "no more sends"
close(ch)
// range over channel — drains until closed
for v := range ch { fmt.Println(v) }
// select — multiplex channels (like epoll, switch on channels)
select {
case msg := <-ch1:
fmt.Println("from ch1:", msg)
case msg := <-ch2:
fmt.Println("from ch2:", msg)
case <-time.After(1 * time.Second):
fmt.Println("timeout")
default:
fmt.Println("non-blocking: nothing ready")
}
jobs := make(chan int, 100)
results := make(chan int, 100)
for w := 0; w < 3; w++ { // 3 workers
go func() {
for j := range jobs {
results <- j * j
}
}()
}
for j := 1; j <= 9; j++ { jobs <- j }
close(jobs)
for r := 0; r < 9; r++ { fmt.Println(<-results) }
No implements keyword. If your type has the methods, it satisfies the interface. This is structural typing — like TypeScript's duck typing, but at compile time.
type Stringer interface {
String() string
}
type Person struct{ Name string }
// Person now implicitly satisfies Stringer
func (p Person) String() string {
return "Person: " + p.Name
}
func printIt(s Stringer) {
fmt.Println(s.String())
}
printIt(Person{"Alice"}) // works — no declaration needed
// interface{} (or "any" since Go 1.18) accepts any value
func printAny(v any) {
fmt.Printf("%T: %v\n", v, v) // %T prints the type
}
// type assertion
var i any = “hello”
s, ok := i.(string) // safe assertion
// type switch
switch v := i.(type) {
case string: fmt.Println(“string:”, v)
case int: fmt.Println(“int:”, v)
default: fmt.Println(“unknown”)
}
Key stdlib interfaces to know: io.Reader, io.Writer, error, fmt.Stringer, sort.Interface. Composing these is how you write idiomatic Go.
No exceptions. Errors are values of type error — an interface with one method: Error() string. You handle them or ignore them explicitly.
// creating errors
import "errors"
var ErrNotFound = errors.New("not found") // sentinel error
// custom error type
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
// wrapping errors (Go 1.13+) — like cause-chaining in Java
func fetchUser(id int) (User, error) {
u, err := db.Find(id)
if err != nil {
return User{}, fmt.Errorf("fetchUser %d: %w", id, err) // %w wraps
}
return u, nil
}
// unwrapping
errors.Is(err, ErrNotFound) // checks chain
errors.As(err, &valErr) // extracts type from chain
// panic/recover — only for truly unexpected situations (like assert)
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
The repetitive if err != nil is intentional. Go treats error handling as a first-class concern, not an afterthought. Proposals to improve this syntax exist but nothing has landed yet.
Go modules replaced GOPATH in Go 1.11. A module is a collection of packages, versioned together.
# create a new module
go mod init github.com/you/myapp
# add dependency (updates go.mod + go.sum)
go get github.com/some/pkg@v1.2.3
# tidy — remove unused deps
go mod tidy
# build + run
go build .
go run main.go
# test (files ending in _test.go)
go test ./...
go test -race ./... # data race detector — use this!
# format (no debates — one canonical format)
go fmt ./...
gofmt -w .
# static analysis
go vet ./...
package mylib
type PublicStruct struct { // exported — visible outside package
PublicField string // exported
privateField string // unexported — package-private
}
func helperFunc() {} // unexported
func PublicFunc() {} // exported
| Gotcha | Explanation |
|---|---|
| nil interface ≠ nil pointer | An interface holding a typed nil pointer is NOT nil. Classic source of bugs when returning concrete error types. |
| Slice shares backing array | s2 := s1[1:3] shares memory. Appending to s2 beyond cap will reallocate, but mutations within cap affect s1. |
| map is not safe for concurrent writes | Use sync.Mutex or sync.Map. The race detector will catch misuse. |
| String is bytes, not runes | Strings are UTF-8 byte slices. len(s) = bytes. Range over string iterates runes. Use []rune(s) for Unicode-safe ops. |
| Goroutine leak | Goroutines waiting on a channel that nobody closes will live forever. Use context.Context for cancellation. |
| Zero values are useful | Every type has a zero value (0, "", false, nil). var mu sync.Mutex is valid and ready to use — no constructor needed. |
| Named return values | func f() (n int, err error) — named returns initialize to zero. Bare return returns them. Useful but use sparingly. |
| init() runs automatically | Each package can have multiple init() functions. They run after all variable initializations, before main. |
func doWork(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err() // cancelled or timed out
default:
// do one unit of work
}
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() // always call cancel to release resources
doWork(ctx)
type Server struct{ port int; timeout time.Duration }
type Option func(*Server)
func WithPort(p int) Option { return func(s *Server) { s.port = p } }
func NewServer(opts …Option) *Server {
s := &Server{port: 8080}
for _, o := range opts { o(s) }
return s
}
srv := NewServer(WithPort(9090))
func TestAdd(t *testing.T) {
tests := []struct{ a, b, want int }{
{1, 2, 3},
{0, 0, 0},
{-1, 1, 0},
}
for _, tc := range tests {
if got := Add(tc.a, tc.b); got != tc.want {
t.Errorf("Add(%d,%d) = %d, want %d", tc.a, tc.b, got, tc.want)
}
}
}
// type constraint
type Number interface {
~int | ~float64
}
func Sum[T Number](nums []T) T {
var total T
for _, n := range nums { total += n }
return total
}
Sum([]int{1, 2, 3}) // type inferred
Sum[float64]([]float64{1.5}) // explicit
// ~int means “int or any type with underlying type int”
Essential stdlib packages to know: fmt, os, io, bufio, strings, strconv, time, sync, context, net/http, encoding/json, testing, log/slog (1.21+)
http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %s", r.URL.Query().Get("name"))
})
http.ListenAndServe(":8080", nil) // nil = DefaultServeMux