Go Tutorial: Learn Systems Programming from Scratch (2026)
Go is the language Google created for large-scale server software. I have used it for API gateways, CLI tools, and data pipelines. The language is intentionally small: no classes, no inheritance, no generics (until 1.18), no exceptions. What remains is surprisingly effective for concurrent networked systems.
What struck me most is how uniform Go code looks across different organizations. Gofmt enforces style, simple error handling eliminates surprises, and the lack of metaprogramming keeps codebases readable.
Goroutines
A goroutine is a lightweight thread managed by the runtime, starting with a few KB of stack that grows as needed. The go keyword spawns one. The scheduler multiplexes goroutines onto OS threads. You can launch thousands in a single process. For CPU-bound work, limit concurrency to GOMAXPROCS.
func worker(id int, jobs <-chan int, res chan<- int) {
for j := range jobs {
res <- j * 2
}
}
func main() {
jobs := make(chan int, 100)
res := make(chan int, 100)
for w := 0; w < 5; w++ {
go worker(w, jobs, res)
}
}
Channels and Select
Channels are typed pipes for goroutine communication. Unbuffered channels synchronize: a send blocks until a receiver is ready. Buffered channels decouple them up to the buffer size. select waits on multiple channel operations, executing the first ready one. Use it for timeouts, cancellation, and fan-in.
func merge(cs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, c := range cs {
wg.Add(1)
go func(ch <-chan int) {
defer wg.Done()
for v := range ch {
out <- v
}
}(c)
}
go func() { wg.Wait(); close(out) }()
return out
}
Interfaces
Interfaces in Go are satisfied implicitly — a type implements an interface just by having the required methods, no implements keyword. This enables loosely coupled designs. The empty interface interface{} accepts any type. Use type assertions (val, ok := x.(string)) to extract concrete values safely.
type Reader interface {
Read(p []byte) (n int, err error)
}
type FileReader struct {
data []byte
pos int
}
func (f *FileReader) Read(p []byte) (int, error) {
if f.pos >= len(f.data) {
return 0, io.EOF
}
n := copy(p, f.data[f.pos:])
f.pos += n
return n, nil
}
Defer and Panic
Defer schedules a function call to run when the surrounding function returns, in LIFO order. Use it for cleanup immediately after acquiring resources. panic stops normal flow and unwinds the stack; recover captures the panic value. Reserve panics for truly unrecoverable states, not routine errors.
func Copy(src, dst string) error {
in, err := os.Open(src)
if err != nil { return err }
defer in.Close()
out, err := os.Create(dst)
if err != nil { return err }
defer out.Close()
_, err = io.Copy(out, in)
return err
}
Structs and Methods
Go has structs, not classes. Methods attach via receiver parameters: func (s *Struct) Method(). Pointer receivers can modify the struct; value receivers work on a copy. Be consistent: if one method uses a pointer receiver, all should. Struct embedding provides composition over inheritance.
type Server struct {
addr string
timeout time.Duration
mux http.Handler
}
func (s *Server) Listen() error {
srv := &http.Server{Addr: s.addr, Handler: s.mux}
return srv.ListenAndServe()
}
func New(addr string) *Server {
return &Server{addr: addr, mux: http.NewServeMux()}
}
Module System
Go modules use go.mod to define the module path and dependencies. Minimal Version Selection uses the minimum version satisfying all imports, avoiding diamond dependency issues. Use go mod tidy to sync go.mod and go.sum. Internal packages are importable only within the same module tree.
// go.mod
example.com/app v1.0.0
require (
github.com/gorilla/mux v1.8.0
)
// main.go
import (
"fmt"
"github.com/gorilla/mux"
)
Frequently Asked Questions
Why no exceptions?
Go returns errors explicitly because exceptions obscure control flow. Every error is a value you handle or ignore intentionally.
What is the zero value?
Variables without initializers get zero values: 0 for numbers, false for bool, "" for strings, nil for pointers, slices, maps, channels.
How to check map key existence?
val, ok := myMap["key"]; ok is true if the key exists.
Buffered vs unbuffered channel?
Unbuffered enforces sync; buffered decouples but can hide deadlocks. Start unbuffered; add buffer only after measurement.
Originally published on Ayodhyyya. Last updated June 1, 2026.