Go (also called Golang) is a statically typed, compiled language built by Google engineers — designed to be fast to write, fast to compile, and fast to run. It powers the infrastructure behind Docker, Kubernetes, Terraform, and thousands of cloud services. This tutorial takes you from zero to writing real Go programs, step by step, with no prior Go experience required.
What you'll learn
| Topic | What you'll be able to do |
|---|---|
| Setup | Install Go and write your first program |
| Syntax & variables | Understand how Go code is structured |
| Types | Work with integers, floats, strings, booleans |
| Control flow | Use if/else, switch, and loops |
| Functions | Write reusable functions with multiple return values |
| Slices & Maps | Use Go's core collection types |
| Structs & Methods | Model data with structs and add behaviour |
| Interfaces | Write flexible, composable code |
| Error handling | Handle errors the Go way |
| Goroutines & Channels | Write concurrent programs |
| Testing | Write and run table-driven tests |
| Projects | Build 3 real programs |
Go version used: Go 1.22+
Part 1 — Why Go?
Go was designed to solve real production problems: slow compile times, complex concurrency, and bloated runtimes. It ships as a single static binary, starts in milliseconds, and handles millions of concurrent requests with ease.
Go use cases
| Domain | Examples |
|---|---|
| Web services & APIs | REST APIs, gRPC microservices |
| Cloud infrastructure | Kubernetes, Terraform, Docker |
| CLI tools | GitHub CLI, Hugo, golangci-lint |
| DevOps & automation | CI/CD pipelines, deployment tools |
| Networking | Proxies, load balancers, DNS servers |
| Systems programming | Databases, storage engines |
Go vs Python vs Java vs Rust
| Dimension | Go | Python | Java | Rust |
|---|---|---|---|---|
| Speed | Very fast (compiled) | Slow (interpreted) | Fast (JVM) | Fastest (no GC) |
| Simplicity | Very simple (25 keywords) | Simple | Verbose | Complex |
| Concurrency | Goroutines (built-in) | GIL limits threads | Threads/async | Async/threads |
| Ecosystem | Growing, focused | Enormous | Mature, enterprise | Growing, systems |
| Best for | Cloud, CLIs, services | Data science, scripting | Enterprise backends | Systems, safety-critical |
Part 2 — Setup
Install Go
# Windows (using winget)
winget install GoLang.Go
# macOS (using Homebrew)
brew install go
# Linux (Ubuntu/Debian)
sudo apt install golang-go
# Or download directly from https://go.dev/dl/
Verify the installation:
go version
# go version go1.22.0 linux/amd64
Editor setup
| Editor | Setup |
|---|---|
| VS Code | Install the official Go extension (golang.go) — gets you IntelliSense, formatting, and debugging |
| GoLand | JetBrains IDE, full Go support out of the box |
| Neovim | Use nvim-lspconfig with gopls (Go's language server) |
Hello World
Create a file called hello.go:
package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}
Run it:
go run hello.go
# Hello, World!
Initialise a module
Every Go project is a module. Initialise one with:
mkdir myproject && cd myproject
go mod init github.com/yourname/myproject
This creates a go.mod file:
module github.com/yourname/myproject
go 1.22
Project structure
myproject/
├── go.mod
├── go.sum # dependency checksums (auto-managed)
├── main.go
├── cmd/ # entry points (for larger CLIs)
├── internal/ # private packages
└── pkg/ # reusable public packages
Part 3 — Go Basics
Packages and imports
Every .go file starts with a package declaration. The main package is special — it's the entry point of an executable.
package main
import (
"fmt"
"math"
"strings"
)
Use blank imports _ to run a package's init() for side effects only. Unused imports are a compile error in Go.
Variables
// Long form
var name string = "Alice"
var age int = 30
// Short declaration (inside functions only)
city := "London"
// Multiple assignment
x, y := 10, 20
// Constants
const Pi = 3.14159
const MaxRetries = 3
Zero values
Every variable in Go has a zero value — no uninitialised memory.
| Type | Zero value |
|---|---|
int, int64, etc. |
0 |
float32, float64 |
0.0 |
bool |
false |
string |
"" |
pointer, slice, map |
nil |
struct |
all fields zero |
Basic types
| Type | Size | Range / Notes |
|---|---|---|
int |
platform (64-bit on 64-bit OS) | general-purpose integer |
int8 |
8-bit | -128 to 127 |
int16 |
16-bit | -32768 to 32767 |
int32 |
32-bit | also aliased as rune |
int64 |
64-bit | large integers |
uint, uint8…uint64 |
unsigned variants | uint8 also aliased as byte |
float32 |
32-bit | less precision |
float64 |
64-bit | default for decimals |
complex128 |
two float64s | complex numbers |
bool |
1-bit | true / false |
string |
immutable UTF-8 bytes | "hello" |
byte |
alias for uint8 |
raw bytes |
rune |
alias for int32 |
Unicode code point |
Type conversion
Go never converts types implicitly — you must be explicit:
var x int = 42
var f float64 = float64(x)
var u uint = uint(f)
// String conversions use strconv, not casting
s := strconv.Itoa(x) // int → string
n, err := strconv.Atoi(s) // string → int
Printing
name := "Alice"
age := 30
fmt.Println("Hello,", name) // Hello, Alice
fmt.Printf("Name: %s, Age: %d\n", name, age)
msg := fmt.Sprintf("User: %s", name) // returns string
Common Printf verbs: %s (string), %d (integer), %f (float), %v (any value), %T (type), %+v (struct with field names).
Part 4 — Strings and Numbers
String operations
s := "Hello, Go!"
fmt.Println(len(s)) // 10 (bytes, not runes)
fmt.Println(s[0]) // 72 (byte value of 'H')
fmt.Println(string(s[0])) // "H"
fmt.Println(s[7:]) // "Go!"
// strings package
import "strings"
strings.ToUpper(s) // "HELLO, GO!"
strings.Contains(s, "Go") // true
strings.Replace(s, "Go", "World", 1)
strings.Split("a,b,c", ",") // ["a", "b", "c"]
strings.TrimSpace(" hi ") // "hi"
strings.HasPrefix(s, "Hello") // true
strconv — string/number conversion
import "strconv"
n, err := strconv.Atoi("42") // string → int
s := strconv.Itoa(42) // int → string
f, err := strconv.ParseFloat("3.14", 64) // string → float64
b, err := strconv.ParseBool("true") // string → bool
Always check err — invalid input returns an error.
Math
import "math"
math.Abs(-5.0) // 5.0
math.Sqrt(16.0) // 4.0
math.Pow(2, 10) // 1024.0
math.Floor(3.7) // 3.0
math.Ceil(3.2) // 4.0
math.Max(3.0, 5.0) // 5.0
math.Pi // 3.141592653589793
Part 5 — Control Flow
if / else if / else
score := 85
if score >= 90 {
fmt.Println("A")
} else if score >= 80 {
fmt.Println("B")
} else {
fmt.Println("C")
}
// Initialiser: declare a variable scoped to the if block
if err := doSomething(); err != nil {
fmt.Println("Error:", err)
}
switch
day := "Monday"
switch day {
case "Saturday", "Sunday":
fmt.Println("Weekend")
case "Monday":
fmt.Println("Back to work")
default:
fmt.Println("Weekday")
}
// No explicit fallthrough needed — cases don't fall through by default
// Use fallthrough keyword to opt in
Type switch:
func describe(i interface{}) {
switch v := i.(type) {
case int:
fmt.Printf("int: %d\n", v)
case string:
fmt.Printf("string: %s\n", v)
default:
fmt.Printf("unknown type: %T\n", v)
}
}
for loop
Go has only one loop keyword: for.
// C-style
for i := 0; i < 5; i++ {
fmt.Println(i)
}
// While-style
n := 0
for n < 10 {
n++
}
// Infinite loop
for {
// break to exit
break
}
// Range over slice
nums := []int{1, 2, 3}
for i, v := range nums {
fmt.Println(i, v)
}
// Range over map
m := map[string]int{"a": 1, "b": 2}
for k, v := range m {
fmt.Println(k, v)
}
Use break to exit a loop and continue to skip to the next iteration.
Part 6 — Functions
Basic functions and multiple return values
func add(a, b int) int {
return a + b
}
// Multiple return values — idiomatic Go
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
result, err := divide(10, 3)
if err != nil {
log.Fatal(err)
}
fmt.Println(result)
Named return values
func minMax(nums []int) (min, max int) {
min, max = nums[0], nums[0]
for _, n := range nums[1:] {
if n < min { min = n }
if n > max { max = n }
}
return // naked return — returns named values
}
Variadic functions
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
sum(1, 2, 3) // 6
sum([]int{1,2,3}...) // spread a slice
defer
defer schedules a function call to run when the surrounding function returns. Multiple defers execute in LIFO (last in, first out) order.
func readFile(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // always runs, even on error
// read from f...
return nil
}
Closures
func counter() func() int {
count := 0
return func() int {
count++
return count
}
}
c := counter()
fmt.Println(c()) // 1
fmt.Println(c()) // 2
fmt.Println(c()) // 3
Error handling pattern
// Always return (T, error) from functions that can fail
func fetchUser(id int) (User, error) {
if id <= 0 {
return User{}, fmt.Errorf("invalid user id: %d", id)
}
// ...
}
user, err := fetchUser(42)
if err != nil {
return fmt.Errorf("fetchUser: %w", err) // wrap with context
}
Part 7 — Arrays, Slices, Maps
Arrays
Fixed-size, rarely used directly — prefer slices.
var arr [3]int // [0 0 0]
arr2 := [3]string{"a", "b", "c"}
arr3 := [...]int{1, 2, 3} // compiler counts the elements
Slices
Slices are dynamic views over an underlying array.
// Create
s := []int{1, 2, 3}
s2 := make([]int, 5) // length 5, capacity 5
s3 := make([]int, 3, 10) // length 3, capacity 10
// Append
s = append(s, 4, 5)
// Copy (independent backing array)
dst := make([]int, len(s))
copy(dst, s)
// Slice expressions
s[1:3] // elements at index 1 and 2
s[:2] // first two elements
s[2:] // from index 2 to end
fmt.Println(len(s), cap(s)) // length and capacity
Slice gotcha — shared backing array:
a := []int{1, 2, 3, 4}
b := a[1:3] // b shares memory with a
b[0] = 99
fmt.Println(a) // [1 99 3 4] — a was modified!
// Fix: use copy or append with full slice expression
b = append([]int{}, a[1:3]...) // independent copy
Maps
// Create
m := map[string]int{"alice": 30, "bob": 25}
m2 := make(map[string]int)
// Set and get
m["charlie"] = 28
age := m["alice"]
// Check existence (ok idiom)
age, ok := m["dave"]
if !ok {
fmt.Println("dave not found")
}
// Delete
delete(m, "bob")
// Iterate
for name, age := range m {
fmt.Printf("%s is %d\n", name, age)
}
Part 8 — Structs and Methods
Struct definition and initialisation
type User struct {
Name string
Email string
Age int
}
// Named fields (preferred)
u := User{Name: "Alice", Email: "alice@example.com", Age: 30}
// Positional (fragile — avoid for multi-field structs)
u2 := User{"Bob", "bob@example.com", 25}
// Pointer to struct
up := &User{Name: "Carol"}
Methods
// Value receiver — for read-only methods or small structs
func (u User) Greet() string {
return "Hello, " + u.Name
}
// Pointer receiver — for methods that mutate state
func (u *User) Birthday() {
u.Age++
}
Value vs pointer receivers
| Use | When |
|---|---|
| Value receiver | Method doesn't modify the struct; struct is small |
| Pointer receiver | Method modifies the struct; struct is large (avoid copy) |
| Pointer receiver | Struct contains sync primitives (Mutex, etc.) |
Be consistent: if any method uses a pointer receiver, use pointer receivers for all methods on that type.
Embedding (composition over inheritance)
type Animal struct {
Name string
}
func (a Animal) Speak() string {
return a.Name + " makes a sound"
}
type Dog struct {
Animal // embedded — Dog inherits Animal's methods
Breed string
}
d := Dog{Animal: Animal{Name: "Rex"}, Breed: "Labrador"}
fmt.Println(d.Speak()) // "Rex makes a sound"
Anonymous structs
point := struct {
X, Y int
}{X: 10, Y: 20}
Part 9 — Interfaces
Interface definition and implicit implementation
Go interfaces are satisfied implicitly — no implements keyword needed.
type Stringer interface {
String() string
}
type User struct{ Name string }
// User implements Stringer implicitly
func (u User) String() string {
return "User: " + u.Name
}
var s Stringer = User{Name: "Alice"}
fmt.Println(s.String())
The empty interface
// any (alias for interface{}) accepts any type
func printAnything(v any) {
fmt.Println(v)
}
Type assertion and type switch
var i any = "hello"
// Type assertion
s, ok := i.(string)
if ok {
fmt.Println("string:", s)
}
// Type switch (see Part 5 for example)
Common standard interfaces
| Interface | Package | Purpose |
|---|---|---|
Stringer |
fmt |
String() string — custom print format |
error |
builtin | Error() string — error values |
io.Reader |
io |
Read(p []byte) (n int, err error) |
io.Writer |
io |
Write(p []byte) (n int, err error) |
io.Closer |
io |
Close() error |
http.Handler |
net/http |
ServeHTTP(w, r) |
Interface vs concrete type
| Use | When |
|---|---|
| Interface | Accepting multiple implementations; writing testable code |
| Concrete type | Single implementation; performance-critical inner loops |
Part 10 — Error Handling
The error interface
type error interface {
Error() string
}
Creating errors
import (
"errors"
"fmt"
)
// Simple error
err := errors.New("something went wrong")
// Formatted error
err = fmt.Errorf("user %d not found", id)
// Wrapping an error (preserves the chain)
err = fmt.Errorf("fetchUser: %w", originalErr)
Sentinel errors vs custom types
// Sentinel errors — compare with errors.Is
var ErrNotFound = errors.New("not found")
var ErrPermission = errors.New("permission denied")
// Custom error type — use when you need structured data
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message)
}
errors.Is and errors.As
err := fetchUser(999)
// Check if error matches a sentinel (works through wrap chain)
if errors.Is(err, ErrNotFound) {
fmt.Println("user does not exist")
}
// Extract structured error type
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println("field:", ve.Field)
}
Panic and recover
// panic stops normal execution — use for programmer errors only
func mustPositive(n int) int {
if n <= 0 {
panic(fmt.Sprintf("expected positive, got %d", n))
}
return n
}
// recover captures a panic — use in top-level handlers, not business logic
func safeRun(f func()) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
f()
return nil
}
Part 11 — Goroutines and Channels
Goroutines
A goroutine is a lightweight thread managed by the Go runtime. Creating one is as simple as prefixing a function call with go.
go func() {
fmt.Println("running in background")
}()
Goroutine vs OS thread
| Property | Goroutine | OS thread |
|---|---|---|
| Stack size | ~2 KB (grows dynamically) | ~1–8 MB fixed |
| Creation time | Microseconds | Milliseconds |
| Scheduling | Go runtime (cooperative + preemptive) | OS kernel |
| Communication | Channels | Shared memory + mutexes |
| Practical limit | Hundreds of thousands | Hundreds to thousands |
Channels
// Unbuffered channel — sender blocks until receiver is ready
ch := make(chan int)
go func() {
ch <- 42 // send
}()
val := <-ch // receive
fmt.Println(val)
// Buffered channel — sender blocks only when buffer is full
bch := make(chan string, 3)
bch <- "a"
bch <- "b"
bch <- "c"
// Close signals no more values will be sent
close(bch)
// Range over channel until closed
for msg := range bch {
fmt.Println(msg)
}
Buffered vs unbuffered channels
| Unbuffered | Buffered | |
|---|---|---|
| Synchronisation | Sender and receiver synchronise | Sender can proceed until buffer full |
| Use case | Guaranteed handoff | Decoupling producer/consumer speeds |
| Deadlock risk | Higher | Lower, but buffer can hide bugs |
select statement
ch1 := make(chan string)
ch2 := make(chan string)
select {
case msg := <-ch1:
fmt.Println("ch1:", msg)
case msg := <-ch2:
fmt.Println("ch2:", msg)
case <-time.After(1 * time.Second):
fmt.Println("timeout")
}
sync.WaitGroup pattern
import "sync"
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Println("worker", id)
}(i)
}
wg.Wait() // block until all goroutines finish
sync.Mutex and race detector
var mu sync.Mutex
counter := 0
go func() {
mu.Lock()
counter++
mu.Unlock()
}()
// Detect data races at runtime
// go run -race main.go
// go test -race ./...
Part 12 — Packages and Modules
go.mod and go.sum
module github.com/yourname/myapp
go 1.22
require (
github.com/gin-gonic/gin v1.9.1
)
go.sum contains cryptographic hashes — commit it to version control.
Common commands
go mod init github.com/yourname/myapp # initialise module
go get github.com/some/package # add dependency
go get github.com/some/package@v1.2.3 # specific version
go mod tidy # clean up unused deps
go build ./... # build all packages
go test ./... # test all packages
go vet ./... # static analysis
gofmt -w . # format all files
Standard library highlights
| Package | Purpose |
|---|---|
fmt |
Formatted I/O, printing |
os |
OS interaction: files, env vars, processes |
io |
I/O primitives and interfaces |
net/http |
HTTP client and server |
encoding/json |
JSON encoding and decoding |
time |
Time and duration |
strings |
String manipulation |
strconv |
String/number conversions |
math |
Math functions |
sort |
Sorting slices and custom types |
sync |
Goroutine synchronisation |
context |
Deadlines, cancellation, request-scoped values |
testing |
Test framework |
log |
Simple logging |
errors |
Error creation and inspection |
Part 13 — Working with JSON and HTTP
encoding/json
import "encoding/json"
type User struct {
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age,omitempty"` // omit if zero
}
// Struct → JSON
u := User{Name: "Alice", Email: "alice@example.com", Age: 30}
data, err := json.Marshal(u)
// data: {"name":"Alice","email":"alice@example.com","age":30}
// JSON → Struct
var u2 User
err = json.Unmarshal(data, &u2)
// Pretty-print
pretty, err := json.MarshalIndent(u, "", " ")
fmt.Println(string(pretty))
Simple HTTP server
package main
import (
"encoding/json"
"net/http"
)
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func main() {
http.HandleFunc("/health", healthHandler)
http.ListenAndServe(":8080", nil)
}
HTTP client
import (
"encoding/json"
"net/http"
"io"
)
// GET request
resp, err := http.Get("https://api.example.com/users")
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
// Decode JSON response directly
var users []User
err = json.NewDecoder(resp.Body).Decode(&users)
// POST request
data, _ := json.Marshal(User{Name: "Alice"})
resp, err = http.Post(
"https://api.example.com/users",
"application/json",
bytes.NewBuffer(data),
)
Part 14 — Testing
The testing package
// file: math_test.go
package main
import "testing"
func TestAdd(t *testing.T) {
result := add(2, 3)
if result != 5 {
t.Errorf("add(2, 3) = %d; want 5", result)
}
}
Run tests:
go test ./...
go test -v ./... # verbose output
go test -run TestAdd # run a specific test
go test -race ./... # with race detector
go test -cover ./... # show coverage
Table-driven tests
This is the idiomatic Go pattern for testing multiple inputs:
func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b float64
want float64
wantErr bool
}{
{"normal division", 10, 2, 5.0, false},
{"division by zero", 10, 0, 0, true},
{"negative numbers", -6, 2, -3.0, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := divide(tc.a, tc.b)
if (err != nil) != tc.wantErr {
t.Errorf("unexpected error: %v", err)
}
if !tc.wantErr && got != tc.want {
t.Errorf("divide(%v, %v) = %v; want %v", tc.a, tc.b, got, tc.want)
}
})
}
}
t.Run creates named subtests — run a single one with go test -run TestDivide/normal_division.
Part 15 — Three Projects
Project 1: CLI Todo Manager
A command-line todo list with JSON file persistence.
package main
import (
"encoding/json"
"fmt"
"os"
"strconv"
)
type Todo struct {
ID int `json:"id"`
Text string `json:"text"`
Done bool `json:"done"`
}
const dataFile = "todos.json"
func loadTodos() []Todo {
data, err := os.ReadFile(dataFile)
if err != nil {
return []Todo{}
}
var todos []Todo
json.Unmarshal(data, &todos)
return todos
}
func saveTodos(todos []Todo) error {
data, err := json.MarshalIndent(todos, "", " ")
if err != nil {
return err
}
return os.WriteFile(dataFile, data, 0644)
}
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: todo <add|list|done> [args]")
os.Exit(1)
}
todos := loadTodos()
switch os.Args[1] {
case "add":
if len(os.Args) < 3 {
fmt.Println("Usage: todo add <text>")
os.Exit(1)
}
todo := Todo{ID: len(todos) + 1, Text: os.Args[2]}
todos = append(todos, todo)
saveTodos(todos)
fmt.Printf("Added: %s\n", todo.Text)
case "list":
for _, t := range todos {
status := "[ ]"
if t.Done {
status = "[x]"
}
fmt.Printf("%d. %s %s\n", t.ID, status, t.Text)
}
case "done":
if len(os.Args) < 3 {
fmt.Println("Usage: todo done <id>")
os.Exit(1)
}
id, _ := strconv.Atoi(os.Args[2])
updated := make([]Todo, len(todos))
for i, t := range todos {
if t.ID == id {
t.Done = true
}
updated[i] = t
}
saveTodos(updated)
fmt.Printf("Marked %d as done\n", id)
}
}
go run main.go add "Buy groceries"
go run main.go add "Write tests"
go run main.go list
go run main.go done 1
Project 2: HTTP REST API
A simple in-memory REST API with no external dependencies.
package main
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"sync"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
type Store struct {
mu sync.RWMutex
users map[int]User
next int
}
func NewStore() *Store {
return &Store{users: make(map[int]User), next: 1}
}
func (s *Store) GetAll() []User {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]User, 0, len(s.users))
for _, u := range s.users {
result = append(result, u)
}
return result
}
func (s *Store) Create(name string) User {
s.mu.Lock()
defer s.mu.Unlock()
u := User{ID: s.next, Name: name}
s.users[s.next] = u
s.next++
return u
}
func main() {
store := NewStore()
http.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case http.MethodGet:
json.NewEncoder(w).Encode(store.GetAll())
case http.MethodPost:
var body struct{ Name string `json:"name"` }
json.NewDecoder(r.Body).Decode(&body)
u := store.Create(body.Name)
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(u)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
})
http.HandleFunc("/users/", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/users/"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
_ = id // extend: add Get/Delete by ID
w.WriteHeader(http.StatusNotImplemented)
})
http.ListenAndServe(":8080", nil)
}
go run main.go
curl -X POST http://localhost:8080/users -d '{"name":"Alice"}'
curl http://localhost:8080/users
Project 3: Concurrent URL Checker
Checks a list of URLs concurrently using goroutines and channels.
package main
import (
"fmt"
"net/http"
"sync"
"time"
)
type Result struct {
URL string
Status int
Err error
}
func checkURL(url string) Result {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url)
if err != nil {
return Result{URL: url, Err: err}
}
defer resp.Body.Close()
return Result{URL: url, Status: resp.StatusCode}
}
func checkAll(urls []string) []Result {
results := make([]Result, len(urls))
var wg sync.WaitGroup
for i, url := range urls {
wg.Add(1)
go func(idx int, u string) {
defer wg.Done()
results[idx] = checkURL(u)
}(i, url)
}
wg.Wait()
return results
}
func main() {
urls := []string{
"https://go.dev",
"https://github.com",
"https://pkg.go.dev",
"https://golang.org",
"https://nonexistent.invalid",
}
fmt.Printf("Checking %d URLs concurrently...\n\n", len(urls))
results := checkAll(urls)
for _, r := range results {
if r.Err != nil {
fmt.Printf("FAIL %s — %v\n", r.URL, r.Err)
} else {
fmt.Printf("%d %s\n", r.Status, r.URL)
}
}
}
go run main.go
# Checking 5 URLs concurrently...
# 200 https://go.dev
# 200 https://github.com
# ...
Part 16 — Learning Path
| Stage | Focus | Timeline |
|---|---|---|
| 1. Basics | Syntax, types, control flow, functions | 1–2 weeks |
| 2. Core stdlib | strings, os, io, encoding/json, net/http | 2–3 weeks |
| 3. Concurrency | Goroutines, channels, sync, context | 2–3 weeks |
| 4. Testing | Table-driven tests, benchmarks, testify | 1–2 weeks |
| 5. Web frameworks | Gin or Chi, middleware, routing | 2–4 weeks |
| 6. Production patterns | Structured logging (slog), metrics, graceful shutdown, Docker | Ongoing |
Recommended resources:
- go.dev/tour — official interactive tour
- pkg.go.dev — standard library docs
- The Go Programming Language (Donovan & Kernighan) — the definitive book
- Go by Example — annotated code examples
Common Go Mistakes
| Mistake | Problem | Fix |
|---|---|---|
| Nil pointer dereference | Accessing a method/field on a nil pointer | Check for nil before dereferencing |
| Goroutine leak | Spawning goroutines that never exit | Use context.WithCancel or WaitGroup |
| Ignoring errors | Silently discarding error return values |
Always check err != nil |
Using _ for errors |
result, _ := foo() hides failures |
Only discard errors you are certain are safe |
| Modifying map during range | Undefined behaviour | Copy keys first, then delete |
Variable shadowing with := |
Inner := creates new variable, outer unchanged |
Use = for outer; be explicit |
| Slice append pitfall | append may or may not create new backing array |
Never assume append is safe without copy |
| Misusing defer in loops | Deferred calls accumulate, not execute per iteration | Move deferred code into a helper function |
Go vs Related Terms
| Term | Meaning |
|---|---|
| Go | The programming language |
| Golang | Informal name (used for searchability — the domain was golang.org) |
| goroutine | Lightweight concurrent function, managed by the Go runtime |
| channel | Typed pipe for communicating between goroutines |
| interface | Set of method signatures; satisfied implicitly |
| gofmt | Official code formatter — enforces a single canonical style |
| go vet | Built-in static analyser — catches common bugs |
| golangci-lint | Community meta-linter that runs 50+ linters in one pass |
| Go modules | Dependency management system (replaced GOPATH in Go 1.11) |
| GOPATH | Legacy workspace directory — mostly replaced by modules |
FAQ
Is Go hard to learn? No — Go has only 25 keywords and a deliberately small feature set. Most developers coming from Python or Java are productive within a week. The hardest part is shifting your mental model to explicit error handling and Go's concurrency primitives.
Go vs Python for backend? Go is 10–50x faster than Python for CPU-bound work and handles concurrency natively without workarounds like async/await or multiprocessing. Python wins for data science, ML, and rapid prototyping. For high-throughput APIs or microservices, Go is the stronger choice.
Does Go have generics?
Yes — generics were introduced in Go 1.18 (2022). You can write type-parameterised functions and types using [T any] syntax. The standard library's slices, maps, and cmp packages use generics extensively.
func Map[T, U any](slice []T, f func(T) U) []U {
result := make([]U, len(slice))
for i, v := range slice {
result[i] = f(v)
}
return result
}
How do I manage dependencies in Go?
Go modules handle everything. Use go get package@version to add dependencies, go mod tidy to prune unused ones, and commit both go.mod and go.sum. There is no separate npm install step — go build downloads what's needed automatically.
Go vs Rust — when to choose which? Choose Go when you want fast development speed, easy concurrency, and straightforward deployment (single binary). Choose Rust when you need maximum performance, zero-cost abstractions, or strict memory safety without a garbage collector — for example, embedded systems, OS kernels, or game engines. For most web services and CLIs, Go is the pragmatic choice.
What are the best Go web frameworks?
The standard library net/http is production-ready for many use cases. Popular frameworks include: Gin (fastest, minimal), Chi (idiomatic, composable middleware), Echo (similar to Gin, slightly different API), and Fiber (Express-inspired). For full-stack apps, Templ + HTMX is gaining traction.
Go rewards simplicity. Unlike languages that grow new features every year, Go's designers deliberately keep the language small — what you learn today will still be valid in five years. Start with the basics in this tutorial, build the three projects, and you will have a solid foundation to tackle real-world Go applications. The community is welcoming, the tooling is excellent, and the standard library covers most of what you need. Go build something.