Introduction
Go is one of the easiest languages to start with and one of the hardest to truly understand — not because the syntax is hard, but because Go has opinions. No semicolons, no classes, no generics for a decade, one looping construct, and a concurrency model built around tiny green threads called goroutines. Pick it up from a Python or JavaScript background and the first week is half learning new things, half unlearning old reflexes.
This is a complete walkthrough of the language — install, syntax, types, control flow, collections, functions, structs, interfaces, generics, pointers, error handling, and concurrency. By the end you should be comfortable reading any Go codebase and writing a small service of your own.
📚 Table of contents
- Why Go (and where it fits)
- Installing Go and running your first program
- Variables, types, and zero values
- Formatted printing with fmt.Printf
- Type conversion and the strconv package
- Conditionals, switch, and loops
- Arrays vs slices: the part that confuses everyone
- Maps
- Functions: multiple returns, variadics, closures
- Structs and methods
- Interfaces (and why they aren't declared)
- Generics (yes, Go finally has them)
- Pointers in Go
- Errors over exceptions
- Concurrency: goroutines and channels
- Common mistakes
- Pro tips
- FAQs
Why Go (and where it fits)
Go was designed at Google in 2007 by Robert Griesemer, Rob Pike, and Ken Thompson. The pitch was simple: a compiled language with the readability of Python, the safety of Java, and the speed of C, built for the multi-core machines and networked services that were starting to dominate the industry.
Today it powers Docker, Kubernetes, Terraform, Prometheus, parts of Cloudflare, Twitch’s chat, and a huge slice of the CNCF cloud-native stack. The sweet spot is backend services, CLI tools, and infrastructure software — anywhere you’d normally reach for Python or Node but need speed, easy concurrency, and a single static binary you can drop on a server.
Installing Go and running your first program
Grab the installer from go.dev/dl. After install, restart your terminal so the
go binary is on your PATH. Verify with go version.
Create a file called demo.go:
package main
import "fmt"
func main() {
fmt.Println("Hello, Go!")
}
Run it two ways. go run demo.go compiles and executes in one step — great for scripts.
go build demo.go produces a standalone binary called demo (or demo.exe
on Windows) that you can ship anywhere with no runtime needed.
Variables, types, and zero values
Go is statically typed with type inference. Three ways to declare a variable:
var x string // declaration only — x is ""
var y string = "hello" // explicit type + value
z := 42 // short form, type inferred (only inside functions)
The := walrus operator is the one you’ll use 90% of the time. Two rules of Go are worth
putting up front because they catch every newcomer:
- Every declared variable must be used — otherwise the compiler refuses to build. Same goes for imports.
- Every type has a zero value:
""for strings,0for numbers,falsefor bools,nilfor pointers/maps/slices. Noundefinedhere.
The numeric types are explicit: int, int8, int16, int32,
int64, the unsigned variants uint8 through uint64, plus
float32 and float64. int is 64-bit on most modern systems.
Formatted printing with fmt.Printf
fmt.Println is fine for quick prints, but fmt.Printf is where the power lives.
The verbs you’ll use constantly:
%v— the default Go representation of any value%T— the type of a value%d— decimal integer%b— binary,%ooctal,%xhex%f— float, with width control like%.2f%e— scientific notation%s— string%q— quoted string (handy for debugging whitespace)
fmt.Printf("x = %v (type %T)\n", x, x)
fmt.Printf("pi = %.4f\n", 3.14159)
fmt.Printf("binary of 7: %b\n", 7)
Type conversion and the strconv package
Go does not implicitly convert types — ever. int(3.7) works (and truncates to 3), but
int("3") is a compile error. Strings need strconv:
import "strconv"
n, err := strconv.Atoi("42") // string -> int
s := strconv.Itoa(42) // int -> string
f, err := strconv.ParseFloat("3.14", 64)
Notice the second return value: err. Every operation in Go that can fail returns an error
alongside its result. You don’t throw exceptions — you return errors. We’ll cover this
properly in the errors section.
Conditionals, switch, and loops
if looks like any C-family language but you can declare a variable in the condition itself,
which is incredibly handy for error-returning calls:
if n, err := strconv.Atoi(s); err == nil {
fmt.Println("parsed:", n)
}
switch day {
case "Sat", "Sun":
fmt.Println("weekend")
default:
fmt.Println("weekday")
}
for i := 0; i < 10; i++ { ... } // classic for
for cond { ... } // while loop
for { ... } // infinite
for i, v := range items { ... } // range over slice/map/channel
Go has exactly one looping construct: for. There’s no
while or do/while. The three forms above cover every case. switch
cases don’t fall through by default, which is the inverse of C
and saves a thousand bugs.
Arrays vs slices: the part that confuses everyone
Arrays in Go are fixed length. The length is part of the type:
[3]int and [5]int are different types. Pass an array to a function, and the
function gets a copy.
Slices are the everyday container. A slice is a lightweight handle over an underlying array, with three fields: pointer, length, and capacity. Capacity is how far the slice can grow without reallocating.
arr := [3]int{1, 2, 3} // array, fixed size
sl := []int{1, 2, 3} // slice
sl2 := make([]int, 0, 10) // slice with len 0, cap 10
sl = append(sl, 4) // append — may reallocate
fmt.Println(len(sl), cap(sl))
Because a slice carries a pointer, passing one into a function lets that function mutate the underlying data. Passing an array doesn’t. This trips up everyone once — remember: arrays are values, slices are references.
Maps
Hash maps with familiar syntax. Reading a missing key returns the zero value, so always check the second return value when presence matters:
m := map[string]int{"alice": 1, "bob": 2}
m["carol"] = 3
delete(m, "alice")
if v, ok := m["bob"]; ok {
fmt.Println("bob ->", v)
}
Functions: multiple returns, variadics, closures
Functions in Go are first-class values, can return multiple values, accept variable-length argument lists, and form closures. The return-multiple-values pattern is what powers Go’s error-handling style.
func divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
func sum(nums ...int) int { // variadic
total := 0
for _, n := range nums {
total += n
}
return total
}
func counter() func() int { // closure
n := 0
return func() int { n++; return n }
}
Structs and methods
Go doesn’t have classes. It has structs (records) plus methods that can be attached to any type you define. A method is just a function with a receiver:
type Person struct {
Name string
Age int
}
// value receiver — gets a copy
func (p Person) Greet() string {
return "Hi, I'm " + p.Name
}
// pointer receiver — can mutate
func (p *Person) Birthday() {
p.Age++
}
p := Person{Name: "Ada", Age: 36}
fmt.Println(p.Greet())
p.Birthday()
Rule of thumb: use a pointer receiver if the method mutates state or the struct is large. Use a value receiver if it’s small and immutable. Stick to one style per type to keep the API consistent.
Interfaces (and why they aren’t declared)
Interfaces in Go are satisfied implicitly. You don’t write
implements Shape anywhere. If your type has the right method set, it satisfies the
interface automatically.
type Shape interface {
Area() float64
}
type Square struct{ Side float64 }
func (s Square) Area() float64 { return s.Side * s.Side }
type Circle struct{ R float64 }
func (c Circle) Area() float64 { return math.Pi * c.R * c.R }
func totalArea(shapes []Shape) float64 {
sum := 0.0
for _, s := range shapes {
sum += s.Area()
}
return sum
}
This is one of Go’s genuinely elegant features. You can write an interface in your own package that
a third-party type satisfies without that author knowing — great for testing (define a small
interface, mock it) and for composition. The standard library’s io.Reader and
io.Writer are the canonical examples.
Generics (yes, Go finally has them)
Generics arrived in Go 1.18 (March 2022) after a decade of debate. The syntax uses square brackets for type parameters:
func Map[T, U any](xs []T, f func(T) U) []U {
out := make([]U, len(xs))
for i, x := range xs {
out[i] = f(x)
}
return out
}
doubled := Map([]int{1, 2, 3}, func(x int) int { return x * 2 })
any is a built-in alias for interface{}. You can also constrain types with
interfaces or with the constraints package: T constraints.Ordered means
“anything comparable with <.” Use generics sparingly — Go culture still prefers
concrete types and small interfaces.
Pointers in Go
Pointers exist, but with training wheels: no pointer arithmetic. &x gives you a pointer
to x; *p dereferences. The garbage collector handles memory.
x := 10
p := &x // *int pointing at x
fmt.Println(*p) // 10
*p = 42 // mutates x through the pointer
fmt.Println(x) // 42
You’ll see pointers most often as struct receivers and function parameters where mutation is needed. Slices, maps, channels, and functions already carry internal pointers, so you rarely take their address explicitly.
Errors over exceptions
Go does not use exceptions for normal failure modes. Functions return an error as their
last value. The caller checks it. This is verbose but explicit:
data, err := os.ReadFile("config.yml")
if err != nil {
return fmt.Errorf("read config: %w", err)
}
%w wraps the inner error so callers can use errors.Is and errors.As
to inspect the chain. For genuinely unrecoverable situations — invariant violations, programmer
errors — there’s panic() and recover(), but they’re reserved
for exceptional cases.
Concurrency: goroutines and channels
This is the part Go is famous for. A goroutine is a function
that runs concurrently with everything else. You spawn one with the go keyword. Goroutines
are cheap — tens of thousands are routine, millions are possible.
go fetchChat()
go fetchFriends()
go fetchPosts()
But how do you get results back? With channels. A channel is a typed pipe between goroutines. Sending blocks until a receiver is ready; receiving blocks until a sender sends.
ch := make(chan int) // unbuffered
buf := make(chan int, 10) // buffered, holds 10
go func() {
ch <- 42 // send
}()
v := <-ch // receive
// select waits on multiple channels
select {
case msg := <-ch1:
fmt.Println("ch1:", msg)
case msg := <-ch2:
fmt.Println("ch2:", msg)
case <-time.After(time.Second):
fmt.Println("timeout")
}
A common gotcha: every goroutine sending on a channel must have a corresponding receiver, or you get a
deadlock. Buffered channels relax this — a buffered
channel only blocks the sender when full. For coordinated fan-out work, pair channels with
sync.WaitGroup to wait for all goroutines to finish.
Go’s motto here is “don’t communicate by sharing memory; share memory by
communicating.” Instead of locking a shared variable, pass it through a channel. There’s
still sync.Mutex when you need it, but channels are the idiomatic answer most of the time.
❌ Common mistakes
- Treating arrays like slices. They’re copied on assignment and pass-by-value to functions — use slices unless you need a fixed size.
- Ignoring returned errors with
_. If something can fail and you don’t handle it, you have a bug waiting to happen. - Spawning goroutines without coordination —
mainreturns and your goroutines die mid-flight. - Capturing loop variables in closures pre-Go 1.22. Always re-bind:
i := iinside the loop body, or upgrade your Go version. - Using
panicfor everyday errors. Reserve it for impossible-state cases. - Forgetting that maps and slices aren’t safe for concurrent writes — use a mutex or a channel.
💡 Pro tips
- Run
gofmt(or itsgoimportssuperset) on save. Go has one true formatting style and everyone uses it — arguments are over before they start. - Lean on the standard library.
net/http,encoding/json,database/sql— you can build real services with zero third-party dependencies. - Keep interfaces small. The single-method
io.Reader/io.Writerdesign is the idiom; ten-method interfaces are a smell. - Use
context.Contextfor cancellation, deadlines, and request-scoped values. Pass it as the first parameter to any function that does I/O. - Profile with
pprofbefore optimizing. Go ships with first-class profiling tools. - Test with
go testand table-driven tests. The pattern is so consistent in the Go community that any codebase’s tests look the same.
Conclusion
Go is small enough to fit the whole language in your head and serious enough to ship production infrastructure. The hard parts are conceptual, not syntactic — arrays vs slices, value vs pointer receivers, goroutines without channels — and once those click, you’ll write Go that reads like everyone else’s Go.
Next steps: build a small HTTP API with net/http, parse JSON with
encoding/json, and add a database call with database/sql. That covers 80% of
what Go does in the wild.
Explore More on DevShelf
-
Best Programming Languages to Learn in 2026
Where Go fits in the 2026 language landscape — and how to decide between Go, Rust, Python, and TypeScript for your goals.
-
Famous CS Algorithms Explained
Core algorithm families every Go backend engineer encounters — sorting, BFS/DFS, and pathfinding with Big O analysis.