GoGolangInterviewBackendConcurrency

Go Syntax Recap: Learn Go Fast

Go (Golang) syntax recap for coding interviews and fast ramp-up: slices, goroutines, channels, interfaces, errors, and generics, with runnable examples.

Kashyap Kumar·
Go Syntax Recap: Learn Go Fast

Go is a deliberately small language. It gives you one loop keyword, no exceptions, no inheritance, and a concurrency model built into the syntax rather than a library.

This article will teach you Go or Golang syntax and all important stuffs in one sitting. If you have a Go interview it's a quick recap for you and if you're learning it first time then also it'll teach you Go faster than any tutorial. Every concept is paired with code you can run.

If you're preparing for a backend round, read it alongside the framework-agnostic Top 111 Backend Interview Questions. If in case you happen to be a PHP developer as well, you can read PHP Syntax Recap.

How to Read and Run Go

Every Go program is a set of packages, and an executable starts in package main at the func main() entry point. Imports are explicit and, importantly, an unused import or an unused local variable is a compile error, not a warning, which keeps files clean but surprises newcomers. Identifiers that start with an uppercase letter are exported (visible outside their package); lowercase ones are package-private. Go inserts semicolons for you, so you never write them, and gofmt enforces one canonical formatting so style debates do not exist.

package main

import (
    "fmt"      // used below; an unused import would fail to compile
    "strings"
)

// main is the entry point of an executable program.
func main() {
    msg := strings.ToUpper("hello")
    fmt.Println(msg) // HELLO
}

The commands you actually use are few:

  • go run main.go compiles and runs in one step, for quick iteration.
  • go build produces a standalone binary with no runtime dependency.
  • go test ./... runs every test in the module.
  • go vet ./... catches suspicious code that compiles but is probably wrong.
  • gofmt -w . (or go fmt) formats your code to the canonical style.

Variables and Types

Declaring variables

Go gives you two ways to declare a variable, and the difference is where you can use each. The var keyword works everywhere and lets you state the type explicitly; the short form := infers the type from the value but only works inside a function. Every variable you declare must be used, and you can assign several at once.

var count int = 42       // explicit type
var name = "Ada"         // type inferred as string
age := 36                // short form; only inside functions
x, y := 1, 2             // multiple assignment
x, y = y, x              // swap without a temp variable

var ready bool           // declared without a value: gets the zero value (false)

Zero values

Go has no concept of an uninitialized variable: anything you declare without a value gets a well-defined zero value, which removes a whole class of "undefined" bugs. Knowing the zero values matters because idiomatic Go relies on them (a zero-value sync.Mutex is ready to use, a nil slice is safe to append to).

  • Numeric types (int, float64, and so on): 0
  • bool: false
  • string: "" (empty, not nil)
  • Pointers, slices, maps, channels, functions, interfaces: nil
  • A struct: every field set to its own zero value

The basic types

  • Booleans: bool.
  • Integers: int, int8, int16, int32, int64 and unsigned uint, uint8 ... uint64. Plain int is 64-bit on modern platforms.
  • Aliases you see constantly: byte (an alias for uint8) and rune (an alias for int32, used for a Unicode code point).
  • Floats: float32, float64 (default for a decimal literal).
  • Strings: string, which is immutable and holds UTF-8 bytes.

Type conversions

Go never converts between types implicitly, not even between numeric types, so you convert explicitly with T(v). This is stricter than C, Java, or JavaScript, and it is intentional: an unexpected int-to-float promotion cannot silently change a result.

i := 42
f := float64(i)      // int to float64, required; there is no implicit promotion
u := uint(f)         // float64 to uint (truncates toward zero)
b := []byte("hi")    // string to byte slice
s := string(b)       // byte slice back to string
r := string(rune(65)) // "A": an int code point to its string, via rune

Named types and constants

You create a new named type with type, which is how you give meaning to a plain value (a type UserID int cannot be mixed up with an ordinary int by accident). Constants are declared with const, and the iota counter inside a const block generates successive values, which is Go's idiom for enumerations.

type Celsius float64          // a distinct type, not just an alias
type Status int

const (
    Pending  Status = iota // 0
    Active                 // 1 (iota increments automatically)
    Archived               // 2
)

const MaxRetries = 3          // untyped constant, adapts to context

Operators

Go's operators are conventional, with a few pointed differences from other languages worth flagging up front:

  • Arithmetic: + - * / %, where / on two integers truncates toward zero and % is integer remainder. There is no ** operator; use math.Pow for exponentiation.
  • Comparison: == != < <= > >=. Two structs are comparable with == if all their fields are comparable, which is convenient and has no equivalent in most languages.
  • Logical: && || !, with short-circuit evaluation.
  • Bitwise: & | ^ << >> plus &^, the "AND NOT" (bit clear) operator that is unusual to Go.
  • Assignment: =, :=, and the compound forms +=, -=, and so on.

Two things routinely trip people up. First, Go has no ternary operator, so a conditional value needs a full if/else. Second, x++ and x-- are statements, not expressions, so you cannot write y := x++ or arr[x++].

7 / 2        // 3   (integer division truncates)
7 % 2        // 1
1 << 4       // 16  (left shift)
0b1100 &^ 0b0100 // 8: clears the bits set in the right operand

count++      // a statement on its own line, never inside an expression

Strings, Bytes, and Runes

Strings are immutable UTF-8 bytes

A Go string is a read-only slice of bytes holding UTF-8 text, so indexing with s[i] gives you a single byte, not a character, and len(s) returns the number of bytes. To iterate over actual characters (runes), use range, which decodes one UTF-8 code point per step and gives you its starting byte index. This distinction is the source of most string bugs when the text is not plain ASCII.

s := "héllo"
len(s)        // 6, not 5: 'é' takes two bytes in UTF-8
s[0]          // 104, the byte value of 'h' (a byte, not "h")

for i, r := range s {         // r is a rune (int32), i is the byte offset
    fmt.Printf("%d:%c ", i, r) // 0:h 1:é 3:l 4:l 5:o
}

utf8.RuneCountInString(s)     // 5: the real character count

Raw strings and conversions

Backtick-delimited raw string literals span multiple lines and ignore escape sequences, which is ideal for regexes, JSON blobs, and templates. Because strings are immutable, building one up character by character with + reallocates every time; use strings.Builder in a loop to avoid that cost.

raw := `C:\path\no\escapes
spanning multiple lines`     // no \n processing, no escaping needed

var b strings.Builder
for i := 0; i < 3; i++ {
    b.WriteString("go")      // efficient: no per-iteration reallocation
}
b.String()                   // "gogogo"

The strings and strconv packages

String operations live in the strings package (there are no string methods), and conversions between strings and numbers live in strconv.

strings.Contains("seafood", "foo")   // true
strings.HasPrefix("golang", "go")    // true
strings.Index("chicken", "ken")      // 4 (-1 if absent)
strings.Split("a,b,c", ",")          // ["a", "b", "c"]
strings.Join([]string{"a", "b"}, "-")// "a-b"
strings.ReplaceAll("aaa", "a", "b")  // "bbb"
strings.ToUpper("hi")                // "HI"
strings.TrimSpace("  hi  ")          // "hi"
strings.Fields("  a  b   c ")        // ["a", "b", "c"] (split on any whitespace)
strings.Repeat("ab", 3)              // "ababab"

strconv.Atoi("42")                   // 42, nil   (string to int, returns an error)
strconv.Itoa(42)                     // "42"
strconv.ParseInt("ff", 16, 64)       // 255, nil  (base 16)
strconv.ParseFloat("3.14", 64)       // 3.14, nil
strconv.FormatInt(255, 16)           // "ff"

The trap to remember: strconv.Atoi returns two values, the number and an error, so you must handle the error rather than ignore it, which is how Go signals that "abc" is not a valid integer.

Arrays, Slices, and Maps

Arrays versus slices

Go has both fixed-size arrays and dynamically-sized slices, and in practice you almost always use slices. An array's length is part of its type ([3]int and [4]int are different types) and assigning or passing an array copies it, which is why arrays are rare. A slice is a lightweight view into an underlying array, so it is cheap to pass around and is what every collection API expects.

var arr [3]int          // a fixed array of 3 ints, all zero: [0 0 0]
arr[0] = 1

s := []int{1, 2, 3}     // a slice literal (note: no size in the brackets)
s = append(s, 4)        // grow it; append returns a new slice header
first3 := s[0:3]        // slicing: elements 0, 1, 2 (half-open range)

How slices actually work

A slice is a small three-field header: a pointer to a backing array, a length (how many elements are in view), and a capacity (how many the backing array can hold from the pointer onward). Understanding this explains every slice surprise:

  • len(s) is the number of usable elements; cap(s) is how far it can grow before reallocating.
  • append writes into spare capacity if there is room, mutating the shared backing array in place; when there is no room, it allocates a new, larger array and copies, so the original and the result no longer share memory.
  • Because append may or may not reallocate, you must always assign its result back: s = append(s, x).
  • Two slices created from the same array share elements, so writing through one is visible through the other until one of them reallocates.

A Go slice header of pointer, length, and capacity viewing into a backing array
A Go slice header of pointer, length, and capacity viewing into a backing array

Working with slices

s := make([]int, 3, 10)   // len 3, cap 10; the 3 elements are zero
len(s)                    // 3
cap(s)                    // 10

s = append(s, 1, 2)       // append several at once
s = append(s, other...)   // spread another slice with ...

dst := make([]int, len(src))
copy(dst, src)            // copy element-by-element into an existing slice

// Remove index i (order not preserved cheaply):
s = append(s[:i], s[i+1:]...)

// A nil slice is a valid empty slice: len 0, and append works on it.
var empty []int
empty = append(empty, 1)  // fine, no make() needed

Two-dimensional slices

Go has no built-in 2D slice, so you build a slice of slices, allocating each row. This matters in grid problems, where forgetting to allocate the inner rows gives you a nil-slice panic on write.

rows, cols := 3, 4
grid := make([][]int, rows)
for i := range grid {
    grid[i] = make([]int, cols) // each row must be allocated
}
grid[2][3] = 9

Maps

A map is Go's hash table, written map[Key]Value. You create one with make or a literal, and reading a missing key returns the value's zero value rather than an error, so Go gives you the "comma ok" form to tell "missing" apart from "present but zero." Iteration order is deliberately randomized, so never rely on it.

m := map[string]int{"ada": 90, "lin": 70}
m["joy"] = 85            // insert or update
score := m["ada"]        // 90
missing := m["nobody"]   // 0, the zero value (no error)

v, ok := m["joy"]        // comma-ok: v is 85, ok is true
_, exists := m["nobody"] // exists is false

delete(m, "lin")         // remove a key
len(m)                   // number of entries

for key, val := range m { // NOTE: iteration order is random each run
    _ = key; _ = val
}

Go has no built-in set, so the idiom is a map whose value is the empty struct struct{}{}, which takes zero bytes:

seen := map[string]struct{}{}
seen["a"] = struct{}{}          // add
if _, ok := seen["a"]; ok {     // membership test
    // present
}

The slices and maps packages

Since Go 1.21 the standard library includes generic helpers that replace a lot of hand-written loops, plus min, max, and clear as builtins.

slices.Sort(s)                       // sort in ascending order, in place
slices.Contains(s, 3)                // true if 3 is present
slices.Index(s, 3)                   // first index of 3, or -1
slices.Max(s); slices.Min(s)         // largest / smallest element
slices.Reverse(s)                    // reverse in place
slices.Equal(a, b)                   // element-wise equality

min(3, 7)                            // 3 (builtin since Go 1.21)
max(3, 7)                            // 7
clear(m)                             // remove all entries from a map

In a coding round, slices and maps cover almost everything: a map is your hash map and, with struct{} values, your set; a []T is your dynamic array, stack (append and re-slice), and queue (though popping the front is O(n), so use a real structure for heavy queue work).

Control Flow

if and switch

An if has no parentheses around the condition and can start with a short init statement, scoped to the if, which is the idiomatic place to call a function and check its error in one line. A switch in Go does not fall through by default (each case breaks on its own), you can omit the condition to write a clean if/else chain, and a type switch branches on the dynamic type of an interface value.

if v := compute(); v > 0 {       // init statement + condition; v is scoped here
    fmt.Println(v)
} else {
    fmt.Println("non-positive")
}

switch day {
case "sat", "sun":               // multiple values per case
    fmt.Println("weekend")
case "mon":
    fmt.Println("monday")
    fallthrough                  // opt in to fall through explicitly
default:
    fmt.Println("weekday")
}

switch {                         // no condition: acts like if/else-if
case score >= 90:
    grade = "A"
case score >= 80:
    grade = "B"
}

The for loop

Go has exactly one loop keyword, for, which covers every looping need in four shapes:

  • Three-clause C-style: for i := 0; i < n; i++ { }.
  • Condition-only, which is Go's while: for cond { }.
  • No clause, an infinite loop you exit with break: for { }.
  • Range form over a collection: for i, v := range s { }.

range adapts to what you iterate, and since Go 1.22 you can even range over an integer:

for i, v := range []string{"a", "b"} { } // index and value of a slice
for k, v := range someMap { }            // key and value of a map (random order)
for i, r := range "héllo" { }            // byte index and rune of a string
for v := range ch { }                    // values from a channel until it closes
for i := range 5 { }                     // 0,1,2,3,4 (Go 1.22+: range over an int)

break and continue accept a label so you can escape or skip an outer loop from inside a nested one, which is invaluable for grid and matrix problems:

outer:
for _, row := range grid {
    for _, cell := range row {
        if cell == target {
            break outer            // exit both loops at once
        }
    }
}

Functions

Multiple return values and named returns

Functions are first-class values, and the defining Go feature is that they return multiple values, which is how the language reports errors without exceptions. You can name the return values, which documents them and lets you use a bare return, though heavy use of named returns tends to hurt readability.

func divmod(a, b int) (int, int) {   // two return values
    return a / b, a % b
}
q, r := divmod(17, 5)                // 3, 2

func split(sum int) (x, y int) {     // named returns
    x = sum * 4 / 9
    y = sum - x
    return                           // bare return uses the named values
}

Variadic functions and closures

A variadic parameter, written ...T, accepts any number of trailing arguments as a slice, and you spread an existing slice into it with .... A closure is a function literal that captures variables from its surrounding scope by reference, which is how you carry state into callbacks.

func sum(nums ...int) int {          // nums is a []int inside the function
    total := 0
    for _, n := range nums {
        total += n
    }
    return total
}
sum(1, 2, 3)                         // 6
sum(existing...)                     // spread a slice

func counter() func() int {          // returns a closure
    count := 0
    return func() int {
        count++                      // captures and mutates count
        return count
    }
}
next := counter()
next(); next()                       // 1, then 2

defer, panic, and recover

defer schedules a function call to run when the surrounding function returns, no matter how it returns, which is the clean way to release resources such as files, locks, and connections. Deferred calls run in last-in-first-out order, and their arguments are evaluated at the point of defer, not when they actually run, which is a common surprise.

func readFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close()                  // runs when readFile returns, however it returns

    i := 0
    defer fmt.Println("deferred i =", i) // prints 0: i was captured at defer time
    i = 99
    return nil
}

panic stops normal flow and unwinds the stack running deferred calls, and recover, called inside a deferred function, stops that unwinding and returns the panic value. This pair is Go's last-resort mechanism for truly exceptional situations, not its everyday error handling.

func safeDivide(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {       // catch the panic
            err = fmt.Errorf("recovered: %v", r)
        }
    }()
    return a / b, nil                       // a divide-by-zero panics; recover turns it into err
}

Structs, Methods, and Interfaces

Go has no classes and no inheritance. Instead it composes behavior from structs, methods, and interfaces, which together cover what other languages do with objects but with different rules.

Structs

A struct groups named fields into a single type. You create one with a literal (positional or, better, by field name), and Go structs are value types, so assigning one copies every field. Two structs of the same type are comparable with == when all their fields are comparable. Struct tags, the backtick strings after a field, attach metadata that packages like encoding/json read at runtime.

type User struct {
    ID    int
    Name  string    `json:"name"`     // struct tag: JSON key becomes "name"
    Email string    `json:"email,omitempty"`
}

u := User{ID: 1, Name: "Ada"}         // named fields (preferred)
u2 := User{2, "Lin", "[email protected]"}      // positional (fragile; avoid)
p := &User{Name: "Joy"}               // &T{} makes a pointer to a new struct

u.Name                                // field access with . on value or pointer
p.Name                                // Go auto-dereferences: no (*p).Name needed

Methods and receivers

A method is a function with a receiver argument written before the name, which binds it to a type. The receiver can be a value (the method gets a copy) or a pointer (the method can mutate the original). Choosing between them follows clear rules:

  • Use a pointer receiver when the method modifies the receiver, or when the struct is large and you want to avoid copying it.
  • Use a value receiver for small, immutable types where a copy is cheap and mutation is not wanted.
  • Be consistent: if any method needs a pointer receiver, give all methods on that type pointer receivers, so the method set stays uniform.
type Counter struct{ n int }

func (c Counter) Value() int { return c.n }  // value receiver: reads a copy
func (c *Counter) Inc()      { c.n++ }        // pointer receiver: mutates the original

c := Counter{}
c.Inc()           // Go takes the address automatically for you
c.Value()         // 1

Embedding for composition

Go replaces inheritance with embedding: put one type inside another without a field name, and its fields and methods are promoted to the outer type. The outer type does not become a subtype, but it gains the embedded behavior, which is composition made syntactically convenient.

type Animal struct{ Name string }
func (a Animal) Speak() string { return a.Name + " makes a sound" }

type Dog struct {
    Animal          // embedded (no field name)
    Breed string
}

d := Dog{Animal: Animal{Name: "Rex"}, Breed: "Lab"}
d.Name            // "Rex": promoted from Animal
d.Speak()         // "Rex makes a sound": promoted method

Interfaces

An interface is a set of method signatures, and a type satisfies it implicitly simply by having those methods; there is no implements keyword and no declared relationship. This is the single biggest design difference from Java or C#, and it is why Go interfaces are usually tiny (often one method) and defined by the code that consumes them, not the code that provides them.

type Shape interface {
    Area() float64                   // any type with this method satisfies Shape
}

type Circle struct{ R float64 }
func (c Circle) Area() float64 { return math.Pi * c.R * c.R }

// Circle satisfies Shape automatically; no declaration is needed.
var s Shape = Circle{R: 2}
s.Area()                             // 12.566...

The empty interface, spelled any since Go 1.18, holds a value of any type. You get the concrete value back out with a type assertion (in comma-ok form so a wrong guess does not panic) or a type switch.

var x any = "hello"

str, ok := x.(string)   // type assertion, comma-ok form: str="hello", ok=true
n, ok := x.(int)        // ok=false, n=0 (no panic because of the comma-ok form)

switch v := x.(type) {  // type switch: branch on the dynamic type
case string:
    fmt.Println("string of length", len(v))
case int:
    fmt.Println("int", v)
default:
    fmt.Println("something else")
}

An interface value is a (type, value) pair

Internally, an interface value holds two words: the concrete type it currently stores and a pointer to the value. An interface is nil only when both are nil. This is the mechanism behind the notorious typed-nil bug: if you store a nil pointer of a concrete type in an interface, the interface holds a non-nil type, so it compares as not equal to nil even though the underlying pointer is nil.

A Go interface value shown as a type word and a value word, illustrating the typed-nil trap
A Go interface value shown as a type word and a value word, illustrating the typed-nil trap

Error Handling

Errors are values

Go has no exceptions for ordinary failures. Instead a function that can fail returns an error as its last value, and the caller checks it immediately; error is just an interface with a single Error() string method. This makes the failure path explicit and local, at the cost of the repetitive if err != nil you will write constantly.

f, err := os.Open("config.yaml")
if err != nil {
    return err              // handle or propagate; do not ignore
}
defer f.Close()

// Create simple errors with errors.New or fmt.Errorf:
err1 := errors.New("not found")
err2 := fmt.Errorf("user %d: %w", id, err1) // %w wraps err1 so it can be unwrapped

Wrapping, Is, and As

Wrapping an error with the %w verb preserves the original while adding context, building a chain you can inspect. You compare against a known sentinel error with errors.Is, and you extract a specific error type with errors.As; never compare wrapped errors with ==, because the wrapper is a different value.

var ErrNotFound = errors.New("not found")   // a sentinel error

func lookup(id int) error {
    return fmt.Errorf("lookup %d: %w", id, ErrNotFound) // wrap the sentinel
}

err := lookup(7)
errors.Is(err, ErrNotFound)   // true: unwraps the chain to find the sentinel

var pathErr *os.PathError
if errors.As(err, &pathErr) { // true if some error in the chain is *os.PathError
    fmt.Println(pathErr.Path)
}

Custom error types

Any type with an Error() string method is an error, so you define a custom error to carry structured data callers can act on.

type ValidationError struct {
    Field string
    Msg   string
}

func (e *ValidationError) Error() string {
    return e.Field + ": " + e.Msg
}

func validate(name string) error {
    if name == "" {
        return &ValidationError{Field: "name", Msg: "required"}
    }
    return nil
}

When deciding between returning an error and panicking, the rule of thumb is:

  • Return an error for anything a caller could reasonably expect and handle: missing files, bad input, network failures. This is the default.
  • panic only for programmer mistakes and truly unrecoverable states: an impossible switch case, a broken invariant, a nil pointer that should never be nil.
  • recover at trust boundaries (an HTTP handler, a worker goroutine) so one request's panic does not crash the whole process.

Concurrency

Concurrency is built into Go rather than added by a library, which is its headline feature. The model is "do not communicate by sharing memory; share memory by communicating," meaning you coordinate goroutines with channels instead of locks wherever you can. For the conceptual difference between concurrency and parallelism, the Top 111 Backend Interview Questions covers it directly.

Goroutines

A goroutine is a function running concurrently, started by putting go in front of a call. Goroutines are extremely cheap (a few kilobytes of stack, multiplexed onto OS threads by the runtime), so you can have hundreds of thousands. The catch is that main does not wait for them, so you need a way to synchronize, or the program exits while goroutines are still running.

go doWork()                 // runs concurrently; main does not wait
go func() {                 // an anonymous goroutine
    fmt.Println("in a goroutine")
}()

Channels

A channel is a typed conduit you send to with ch <- v and receive from with v := <-ch. Channels come in two kinds, and the difference decides whether operations block:

  • An unbuffered channel (make(chan int)) has no capacity: a send blocks until another goroutine receives, so it synchronizes the two sides at that moment.
  • A buffered channel (make(chan int, n)) holds up to n values: a send blocks only when the buffer is full, and a receive blocks only when it is empty.
ch := make(chan int)        // unbuffered
go func() { ch <- 42 }()    // this send blocks until the receive below runs
v := <-ch                   // 42

buf := make(chan int, 2)    // buffered, capacity 2
buf <- 1                    // does not block
buf <- 2                    // does not block; a third send would block

close(buf)                  // signal that no more values will be sent
for v := range buf {        // ranges until the channel is closed and drained
    fmt.Println(v)          // 1, then 2
}

v, ok := <-buf              // ok is false once a closed channel is drained

Producer goroutines sending values through a channel to a consumer goroutine
Producer goroutines sending values through a channel to a consumer goroutine

select

select waits on several channel operations at once and proceeds with whichever is ready first, picking randomly among several ready cases. A default case makes the whole select non-blocking, and a time.After case gives you a timeout.

select {
case v := <-ch1:
    fmt.Println("from ch1:", v)
case ch2 <- 99:
    fmt.Println("sent to ch2")
case <-time.After(time.Second):
    fmt.Println("timed out after 1s")
default:
    fmt.Println("nothing ready right now") // makes select non-blocking
}

Synchronization with the sync package

When channels are the wrong tool (usually for guarding shared state), the sync package provides classic primitives:

  • sync.WaitGroup waits for a set of goroutines to finish: call Add(n), each goroutine calls Done(), and the main goroutine calls Wait().
  • sync.Mutex gives mutual exclusion with Lock() and Unlock(), usually paired with defer mu.Unlock().
  • sync.RWMutex allows many concurrent readers or one writer.
  • sync.Once runs an initialization exactly once, even under concurrent callers.
var wg sync.WaitGroup
results := make([]int, 5)
for i := range 5 {
    wg.Add(1)
    go func() {                 // Go 1.22+: i is per-iteration, safe to use directly
        defer wg.Done()
        results[i] = i * i      // distinct indices, so no lock needed here
    }()
}
wg.Wait()                       // block until all five goroutines call Done

var mu sync.Mutex
mu.Lock()
counter++                       // protect shared mutable state
mu.Unlock()

Run tests and programs with the -race flag (go run -race .) to catch data races, where two goroutines touch the same memory and at least one writes; the race detector is the fastest way to find these bugs.

Generics

Since Go 1.18 functions and types can take type parameters, letting you write one implementation that works for many types with full type safety, instead of falling back to any and type assertions. A type parameter has a constraint that limits which types are allowed: any allows anything, comparable allows types usable with ==, and you can define your own constraint as an interface of allowed types.

// A generic function: works for any element type.
func Map[T, U any](s []T, f func(T) U) []U {
    out := make([]U, len(s))
    for i, v := range s {
        out[i] = f(v)
    }
    return out
}
doubled := Map([]int{1, 2, 3}, func(n int) int { return n * 2 }) // [2, 4, 6]

// A custom constraint: the ~ allows named types whose underlying type matches.
type Number interface {
    ~int | ~int64 | ~float64
}

func Sum[T Number](s []T) T {
    var total T
    for _, v := range s {
        total += v
    }
    return total
}
Sum([]int{1, 2, 3})          // 6

Standard Library Essentials

Formatting and printing with fmt

fmt handles formatted I/O. Println prints values separated by spaces, Printf uses a format string, and Sprintf returns the formatted string instead of printing it. The verbs you actually use:

  • %v default format, %+v adds struct field names, %#v prints Go syntax.
  • %T the value's type.
  • %d integer, %b binary, %x hex, %o octal.
  • %s string, %q a double-quoted string, %c a rune as a character.
  • %f float, %.2f fixed precision, %g compact float, %e scientific.
  • %t boolean, %p pointer.
fmt.Printf("%s is %d (%.1f%%)\n", "cpu", 3, 42.5) // cpu is 3 (42.5%)
fmt.Printf("%+v\n", User{ID: 1, Name: "Ada"})     // {ID:1 Name:Ada Email:}
s := fmt.Sprintf("%03d", 7)                        // "007"

Fast input and output

Reading input with fmt.Scan is fine for a few values but slow for large inputs, which matters in timed rounds. The fast idiom is a buffered bufio.Scanner for reading and a bufio.Writer for writing, flushed with a deferred Flush.

reader := bufio.NewScanner(os.Stdin)
reader.Buffer(make([]byte, 1024*1024), 1024*1024) // allow long lines
reader.Split(bufio.ScanWords)                     // token-by-token; default is by line
reader.Scan()
n, _ := strconv.Atoi(reader.Text())               // read one integer token

writer := bufio.NewWriter(os.Stdout)
defer writer.Flush()                              // MUST flush, or output is lost
fmt.Fprintln(writer, "answer:", n)

Sorting

Modern code sorts with the generic slices package, while a lot of existing code uses the older sort package; both are worth recognizing.

slices.Sort(nums)                                 // ascending, in place (preferred)
slices.SortFunc(people, func(a, b Person) int {   // custom order: return <0, 0, >0
    return cmp.Compare(a.Age, b.Age)
})

sort.Ints(nums)                                   // the older API
sort.Slice(people, func(i, j int) bool {          // less-than comparator
    return people[i].Age < people[j].Age
})

Heaps and other containers

For priority-queue problems, container/heap turns any type that implements its interface into a heap. It is more verbose than other languages' heaps because you supply the ordering, but the pattern is fixed and worth memorizing.

type IntHeap []int

func (h IntHeap) Len() int           { return len(h) }
func (h IntHeap) Less(i, j int) bool { return h[i] < h[j] } // < gives a min-heap
func (h IntHeap) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }
func (h *IntHeap) Push(x any)        { *h = append(*h, x.(int)) }
func (h *IntHeap) Pop() any {
    old := *h
    n := len(old)
    x := old[n-1]
    *h = old[:n-1]
    return x
}

h := &IntHeap{5, 1, 3}
heap.Init(h)              // establish the heap invariant
heap.Push(h, 2)
heap.Pop(h)               // 1 (the smallest, because Less uses <)

Math and randomness

math.Abs(-3.5)           // 3.5 (operates on float64)
math.Sqrt(144)           // 12
math.Pow(2, 10)          // 1024
math.MaxInt, math.MinInt // integer bounds
math.Inf(1)              // positive infinity
rand.Intn(6)             // a pseudo-random int in [0, 6)

Sharp Edges

A handful of Go behaviors reliably catch both newcomers and returning developers. Each item below pairs the trap with its fix.

  • Nil maps panic on write. A map declared as var m map[string]int is nil, and assigning to it crashes; initialize it with make or a literal first. Nil slices are the exception, since you can append to them directly.
  • Slices share a backing array. A sub-slice points into the same memory, so writes leak across both views, and append may quietly reallocate. Always reassign s = append(s, x), and reach for slices.Clone when you need an independent copy.
  • range copies each element. In for _, v := range s, v is a copy, so writing to it changes nothing. Index the slice instead: for i := range s { s[i] = ... }.
  • A typed nil is not a nil interface. Returning a nil pointer as an error produces an interface that still carries a type, so the caller's err != nil is true. Return a literal nil on the success path.
  • Map iteration order is randomized. Go shuffles range order over a map on every run, so never depend on it. When you need determinism, collect the keys, sort them, and iterate that slice.
  • Integer math has sharp corners. 7 / 2 is 3 because both operands are ints, integer divide-by-zero panics at runtime, and overflow wraps silently (int8(127) + 1 is -128). Widen the type, or reach for math/big.
  • == misses wrapped errors. Once an error is wrapped with %w, err == ErrNotFound is false. Use errors.Is for sentinel values and errors.As for error types, both of which walk the wrap chain.
  • Unbuffered channels deadlock alone. A send blocks until another goroutine receives, so sending from the only running goroutine hangs the whole program. Make sure a receiver exists, or give the channel a buffer.
  • Unused imports and variables fail the build. Go treats them as errors, not warnings. Discard a value with the blank identifier _, and delete any import you are not using.