DS DevShelfHub Projects · AI tools
Articles / Go Programming from Zero to Concurrency: A Complete Hands-On Walkthrough

Careers

Go Programming from Zero to Concurrency: Hands-On Walkthrough

By DevShelfHub

A complete tour of Go for developers coming from Python, JavaScript, or any other language — install, variables, fmt.Printf verbs, strconv, control flow, arrays vs slices, maps, multiple-return functions, structs and methods, implicit interfaces, generics, pointers, error handling, and the goroutine + channel concurrency model. Plus common mistakes and idiomatic style.

Go Programming from Zero to Concurrency: Hands-On Walkthrough

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:

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:

Go
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, 0 for numbers, false for bools, nil for pointers/maps/slices. No undefined here.

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, %o octal, %x hex
  • %f — float, with width control like %.2f
  • %e — scientific notation
  • %s — string
  • %q — quoted string (handy for debugging whitespace)
Go
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:

Go
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:

Go
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.

Go
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:

Go
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.

Go
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:

Go
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.

Go
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:

Go
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.

Go
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:

Go
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
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.

Go
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 — main returns and your goroutines die mid-flight.
  • Capturing loop variables in closures pre-Go 1.22. Always re-bind: i := i inside the loop body, or upgrade your Go version.
  • Using panic for 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 its goimports superset) 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.Writer design is the idiom; ten-method interfaces are a smell.
  • Use context.Context for cancellation, deadlines, and request-scoped values. Pass it as the first parameter to any function that does I/O.
  • Profile with pprof before optimizing. Go ships with first-class profiling tools.
  • Test with go test and 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

Go Programming from Zero to Concurrency: A Complete Hands-On Walkthrough FAQ

Should I learn Go in 2026?

If you're building backend services, CLI tools, or anything cloud-native, yes. The job market for Go has stayed strong because so much infrastructure is written in it. Salaries trend higher than Python/Node averages because the talent pool is smaller.

Is Go better than Rust?

Different tools. Go optimizes for productivity and easy concurrency. Rust optimizes for zero-cost memory safety with no garbage collector. For application servers, Go usually wins on time-to-ship. For systems software with hard memory or latency budgets, Rust often wins.

Why no exceptions?

The Go authors argue that exceptions hide control flow. Every error you can recover from should be visible in the function signature. It's verbose, but errors don't silently bubble out of places you didn't expect.

Goroutines vs threads — what’s the difference?

Goroutines are user-space green threads scheduled by the Go runtime onto a small pool of OS threads. They start at ~2KB stack and grow dynamically. You can have a million goroutines on a single machine; you cannot have a million OS threads.

What’s the most idiomatic way to structure a Go project?

Start flat: one main.go until it hurts. Then split into packages by responsibility, not by layer. A cmd/ folder for binaries and an internal/ folder for non-exported packages is the common pattern. Avoid Java-style service/repository/dto hierarchies.

Which web framework should I use?

Start with net/http from the standard library — since Go 1.22 it has solid routing. Add chi or echo if you need middleware ecosystems. gin is popular but the standard library plus chi usually wins on long-term clarity.

Does Go have inheritance?

No. Composition only. You embed one struct inside another to reuse fields and methods. It's a deliberate choice — the language pushes you toward small interfaces and flat type hierarchies instead.