SM

Command Palette

Search for a command to run...

Blog

Boring on Purpose: Go Basics for People Who Want to Ship

Syed Moinuddin7 min read
GoBackend
Boring on Purpose: Go Basics for People Who Want to Ship

Go's whole pitch is that the basics are enough. A pragmatic tour of the small toolset — structs, slices, errors, goroutines, interfaces — that ships real systems.

Go was designed to be small. Fewer features, fewer ways to do things, fewer footguns. That's not a limitation — it's the whole point, and it's why you can learn the basics in an afternoon and be productive the same week.

Most languages grow by adding things. Go grew by refusing to. No inheritance, no exceptions, no generics for its first decade, no clever metaprogramming. What's left is a language with a tiny surface area that compiles to a single fast binary and is genuinely hard to misuse.

I write Go for backend services and crawlers every day, and the thing I'd tell anyone starting out is this: the basics are most of the language. There's no deep second layer you're missing. Learn these few things well and you can build real systems.

Let's go.

Hello, world (and the toolchain)

A Go program is made of packages. The entry point is main:

package main
 
import "fmt"
 
func main() {
    fmt.Println("Hello, world")
}

Run it with go run main.go. When you're ready to ship, go build compiles it to a single static binary — no runtime to install on the server, no dependency hell. That one fact is why Go deployments are so boring (in the good way): copy one file, run it.

A real project starts with go mod init yourname/project, which creates a module and tracks dependencies in go.mod. That's the entire setup ceremony.

Variables, types, and zero values

Two ways to declare. Inside a function, := infers the type and is what you'll use 90% of the time:

var name string = "explicit"   // full form
age := 30                       // inferred, function scope only
count := 0                      // int
ratio := 3.14                   // float64
ok := true                      // bool

The basic types are what you'd expect: int, float64, string, bool, plus sized variants like int64 and byte.

The one idea that surprises newcomers: every type has a zero value, and Go uses it automatically. No uninitialized garbage. Numbers start at 0, strings at "", booleans at false, and pointers, slices, maps, and interfaces at nil. You can lean on this — a freshly declared var total int is already 0, ready to use.

Functions, and the thing that makes Go Go

Functions are ordinary, but Go has one trick that shapes the entire language: multiple return values.

func divide(a, b int) (int, error) {
    if b == 0 {
        return 0, fmt.Errorf("cannot divide %d by zero", a)
    }
    return a / b, nil
}

That second return value is how Go does error handling — which deserves its own section, because it's the thing people either love or fight.

Error handling: explicit on purpose

Go has no exceptions. A function that can fail returns an error as its last value, and you check it. Immediately. Every time.

result, err := divide(10, 0)
if err != nil {
    log.Printf("division failed: %v", err)
    return
}
fmt.Println(result)

Yes, you'll write if err != nil a lot. That's the trade: there's no hidden control flow, no invisible stack-unwinding, no surprise about where a failure goes. You can read a Go function top to bottom and see every path it can take. After a week it stops feeling verbose and starts feeling like the code is just honest with you.

Structs and methods — no classes required

Go doesn't have classes. It has structs (data) and methods (behavior attached to a type):

type User struct {
    ID    int
    Name  string
    Email string
}
 
// a method with a receiver — this is how behavior attaches to a type
func (u User) Greeting() string {
    return "Hi, " + u.Name
}
 
func main() {
    u := User{ID: 1, Name: "Ada", Email: "ada@example.com"}
    fmt.Println(u.Greeting())
}

Use a pointer receiver (func (u *User)) when the method needs to modify the struct or when the struct is large and you want to avoid copying it. That's most of the design decision right there.

Slices and maps: the two workhorses

You'll reach for these constantly. A slice is a growable list:

nums := []int{1, 2, 3}
nums = append(nums, 4)        // append returns a new slice — reassign it
fmt.Println(len(nums))        // 4
 
for i, n := range nums {      // index and value
    fmt.Println(i, n)
}

A map is a key-value store, with the "comma ok" idiom for checking presence:

ages := make(map[string]int)
ages["ada"] = 36
 
age, ok := ages["ada"]        // ok is false if the key is missing
if ok {
    fmt.Println(age)
}

The one gotcha to internalize: append may return a new underlying array, so always assign its result back. And map iteration order is random by design — never depend on it.

defer: cleanup that can't be forgotten

defer schedules a call to run when the surrounding function returns, no matter how it returns. It's how Go handles cleanup without try/finally:

f, err := os.Open("data.txt")
if err != nil {
    return err
}
defer f.Close()   // runs when the function exits, guaranteed
// ... use f freely; you've already handled closing it

Open a thing, defer closing it on the next line, and forget about it. Files, locks, database rows — same pattern every time.

Goroutines and channels: concurrency that's actually approachable

This is Go's headline feature, and it's simpler than its reputation suggests. Put go in front of a function call and it runs concurrently — a goroutine, which is far lighter than an OS thread. You can run thousands.

go doWork()   // runs concurrently; main keeps going

The problem with concurrency is always communication, and Go's answer is channels — typed pipes that goroutines use to pass values safely:

ch := make(chan string)
 
go func() {
    ch <- "done"          // send into the channel
}()
 
msg := <-ch               // receive (blocks until a value arrives)
fmt.Println(msg)

The Go proverb is don't communicate by sharing memory; share memory by communicating. Instead of locking shared state, you pass ownership of data through channels. It takes one project to click, and then concurrent code stops being scary.

Interfaces: satisfied without saying so

Go interfaces are structural. A type satisfies an interface just by having the right methods — you never write implements. This is quietly one of the best things about the language.

type Stringer interface {
    String() string
}
 
// User automatically satisfies Stringer just by having this method
func (u User) String() string {
    return fmt.Sprintf("User(%d, %s)", u.ID, u.Name)
}

Because satisfaction is implicit, you can define small interfaces where you consume them, and any type with the right shape fits. The community wisdom — "accept interfaces, return structs" — falls right out of this. Keep interfaces tiny; the standard library's io.Reader and io.Writer are each a single method, and they're everywhere.

The handful of gotchas worth knowing early

  • append returns a new slice. Always reassign: s = append(s, x).
  • Map order is random. If you need order, sort the keys yourself.
  • Loop variables were historically reused across iterations — capturing one in a goroutine could bite you. Modern Go fixed this, but if you see old advice about copying the loop variable, that's why.
  • A nil slice is fine to use. You can append to it and range over it. A nil map, however, panics if you write to it — make it first.
  • Unused variables and imports are compile errors. The compiler refuses to let you be sloppy. Annoying on day one, a gift by month two.

The takeaway

Go's whole pitch is that the basics are enough. There's no expert dialect you graduate into — the same small set of tools (structs, slices, maps, errors, goroutines, interfaces) builds everything from a CLI to a high-throughput service. The language stays out of your way so you spend your thinking on the problem, not the syntax.

So write the verbose if err != nil. Lean on zero values. Pass data through channels. Keep your interfaces small. It feels almost too plain at first — and then you ship something, and you realize plain was the feature all along.

FAQ

  1. Is Go a good first language?

    For backend and systems work, it's one of the best. The small feature set means less to learn before you're productive, and the compiler catches a lot of mistakes for you.

  2. Why does Go make me handle every error manually?

    By design. No exceptions means no hidden control flow — every way a function can fail is visible in the code. It's more typing and far less debugging.

  3. Goroutines vs threads — what's the difference?

    Goroutines are managed by Go's runtime and are extremely lightweight, so you can run thousands. The runtime multiplexes them onto a small number of OS threads for you.

  4. Do I need to learn pointers right away?

    Enough to know when to use a pointer receiver on a method (to modify a struct or avoid copying a big one). You can go a long way without manual pointer arithmetic — Go doesn't allow it anyway.

  5. Where do interfaces fit for a beginner?

    Later than the rest, and that's fine. Get comfortable with structs, slices, maps, and errors first; interfaces make more sense once you've felt the duplication they remove.

Sources

  1. S1The Go Programming Language, official site and docs — go.dev/doc
  2. S2A Tour of Go (interactive intro) — go.dev/tour
  3. S3Effective Go (idioms and style) — go.dev/doc/effective_go
  4. S4Go by Example (annotated runnable examples) — gobyexample.com

Written by

Syed Moinuddin

Full Stack Engineer.

Notes on AI tooling, agentic systems, and building things that survive contact with production.

Command Palette

Search for a command to run...