Toolmingo
Guides23 min read

Go Tutorial for Beginners (2025): Learn Golang Step by Step

Complete Go tutorial for beginners. Learn Go syntax, data types, goroutines, and build real projects. Free guide with code examples.

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, uint8uint64 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.

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools