This article is a comprehensive tour of Go's language fundamentals — everything from := and zero values to goroutines, channels, synchronization primitives, and context.Context, along with a deep dive into Go modules and multi-module workspaces.
By the end you will understand the what (Go syntax and stdlib mechanics) and the why behind Go's design choices. If you want to see these fundamentals applied to build a real microservice with Hexagonal Architecture and CQRS, check out the companion article: Building a Microservice with Hexagonal Architecture, CQRS and uber/fx.
go command).go commandsgo mod init - initialises a new module (creates go.mod).go get - adds or updates a dependency.go get github.com/sirupsen/logrus@v1.10.0-u flag tells Go to also upgrade the dependency (and its own dependencies)
to the newest compatible minor or patch version. Without -u,
go get only adds the package if it isn't already present, or locks it to the
version you explicitly specify. Downloaded modules are stored in the module cache at
$GOPATH/pkg/mod (check with go env GOPATH).go mod download - pre-downloads dependencies into the module cache
($GOPATH/pkg/mod) without changing go.mod or go.sum.
What to download comes from go.mod; whether the download
is trustworthy is checked against go.sum (and go.work.sum
in workspace mode). Run it inside one module directory — it does not fetch dependencies for
every module listed in go.work.go mod tidy - synchronises module files with your code:
go.mod;go.mod that the code no longer uses;go.sum checksums accordingly;go build - compiles your code into an executable named after the folder
(or use -o to choose).go run main.go - compiles AND runs immediately (no binary kept around).go test ./... - runs every *_test.go in the module.go test -v = verbose, go test -cover = coverage.
go env - shows Go env vars (GOPATH, GOROOT, etc.).go list -m all - lists every module the project depends on.go work sync - after the workspace picks shared dependency versions, writes that
choice into every module's go.mod. Does not run
automatically after go get — see
Go workspaces below.go mod why -m <module> - answers:
"Who is pulling this dependency into my dependency graph?"
(e.g. go mod why -m google.golang.org/grpc).
go build on Windows produces app.exe; on macOS / Linux it produces an
extension-less binary like ./app. The binary is self-contained - it can be shipped to a machine
that doesn't even have Go installed.
main package is special: it defines the program's entry point and must contain
func main().
main functions in the same package.go.mod file at its
root.// go.mod
module myapp
go 1.20
require github.com/sirupsen/logrus v1.10.0
The module name should match the URL path where the code lives. For a repository
mil.github.io on GitHub under account mil, with the Go
project in a subfolder rest-api, you would run:
cd rest-api
go mod init github.com/mil/mil.github.io/rest-api
This creates a go.mod like:
module github.com/mil/mil.github.io/rest-api
go 1.26.1
The version on the go line comes from the Go compiler installed on your system.
Many projects shorten it to major + minor only (go 1.26) because that is all that
matters for language and toolchain compatibility.
Monorepo pattern: one Git repo, many Go modules — each with its own
go.mod. Name each module after its path in the repo, e.g.
github.com/myorg/myrepo/service-a and
github.com/myorg/myrepo/service-b, so import paths stay unique.
When service-a imports packages from service-b, Go normally
resolves that import by downloading service-b from the remote URL in its
go.mod (e.g. GitHub) — even if both folders sit side by side on your machine.
A root go.work file tells Go: use the local copies in this repo instead.
That lets you change service-a and service-b together, run
go build / go test, and see the effect immediately — without
pushing commits, tagging a release, or running go get against the remote for
every local edit. See Go workspaces below.
go.mod vs go.sumTwo files, two jobs — easy to mix up after a year away from Go modules:
go.mod — the shopping list. It says which dependencies (and
which versions) this module needs. A dependency can be direct (your code
imports it) or indirect (another dependency imports it for you).go.sum — the integrity stamps. It stores checksums so Go can
verify that downloaded code has not been tampered with. It does not decide which
version to use — that always comes from go.mod.
Remember this: seeing a module in go.sum does not mean
your project still uses it. go.sum is more like a log of modules Go has
downloaded or checked in the past. For example,
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705 can sit in
go.sum long after nothing in your code needs it anymore.
One line to keep: go.mod = what we need;
go.sum = checksums for things Go has seen.
When you add a direct dependency, that package may depend on further packages of its own. Those
extra packages are called indirect or transitive dependencies —
you didn't ask for them explicitly, but they're needed transitively. Go records them in
go.mod with an // indirect comment, and their checksums appear in
go.sum:
require (
github.com/gin-gonic/gin v1.10.1 // direct - you added this
github.com/bytedance/sonic v1.11.6 // indirect - gin pulled this in
golang.org/x/crypto v0.23.0 // indirect - gin pulled this in
)
go mod tidy keeps these in sync: it removes any indirect entries that are no longer
reachable from your code's import graph and adds any that are missing.
VS Code with the Go extension auto-adds import statements when you save a file. If that doesn't happen automatically, you can manage imports with:
go fmt — formats code but does not manage import statements.goimports — formats code and automatically adds or removes import
statements. Install it with:
go install golang.org/x/tools/cmd/goimports@latest
$GOPATH/pkg/mod):
go get -u github.com/gin-gonic/gin
server := gin.Default()). VS Code will auto-add the
import statement on save, or run goimports.go mod tidy to update go.mod and go.sum:
go mod tidy
This records the direct dependency (e.g.
require github.com/gin-gonic/gin v1.10.1) plus all its transitive dependencies
in go.mod, and writes their checksums into go.sum.
To change the version of an existing dependency, pass the version tag to go get:
go get github.com/gin-gonic/gin@v1.10.1 # pin to a specific version
go get github.com/gin-gonic/gin@latest # update to the latest released version
go get updates go.mod immediately. Always follow it with
go mod tidy to:
go.sum.go get github.com/gin-gonic/gin@latest
go mod tidy
Rule of thumb: always run go mod tidy after any
go get — whether adding, removing, or updating a dependency — to keep
go.mod and go.sum consistent and avoid "unused dependency" or
"missing checksum" errors later.
If you work in a monorepo with many services, read this section once and bookmark it — it saves
hours when a go get fails with a cryptic import error six months later.
In a monorepo, each service or library has its own pair of files:
cmd/iam-config-management/
go.mod ← what this service needs
go.sum ← checksums for this service
A single module's go.mod might say:
require (
google.golang.org/grpc v1.75.0
)
Translation: "this module needs grpc v1.75.0." Versions always come from
go.mod; go.sum only checks that what was downloaded matches what
was expected.
go.work?
go.work is not another dependency file. It is a list of modules
you want Go to treat as one big project while you develop locally:
use (
./cmd/access-studio
./cmd/auth-gateway
./cmd/iam-config-management
./cmd/user-management
...
)
Without go.work: each module resolves its dependencies on its own.
iam-config-management does not care what auth-gateway uses.
With go.work: Go looks at all listed modules together and
picks versions that satisfy everyone at once. That is useful when services share libraries, but
it also means a problem in one service can block a go get in another.
To add a module to the workspace, run go work use with the module's
path (relative to the repo root). This updates go.work — it does not add
dependencies; those still live in each module's own go.mod:
go work use ./cmd/auth-gateway # add auth-gateway to the workspace
go work use ./cmd/user-management # add user-management to the workspace
Run these from the directory that contains go.work (usually the repo root).
You import one package; Go pulls in everything that package needs, and everything those packages need, and so on. That chain is the dependency tree:
iam-config-management
└── grpc ← you asked for this
├── protobuf ← grpc asked for this
│ └── x/sys ← protobuf asked for this
├── genproto ← grpc asked for this
└── x/net ← grpc asked for this
You may never write import "google.golang.org/genproto/..." in your code, yet
genproto still shows up — because grpc (or something else) depends on it.
When a scanner (e.g. Trivy) flags a module you do not recognise, ask:
"Who is pulling this in?" — that is exactly what go mod why -m is for
(covered below).
Real monorepo scenario. The symptom was simple; the cause was workspace-wide.
Step 1 — the failure. From inside the module you are upgrading
(cmd/iam-config-management):
cd cmd/iam-config-management
go get google.golang.org/grpc@v1.79.3
You might expect go get to affect only the module you are standing in. It does
not — not when a go.work file exists higher up in the folder tree. Here is the
layout:
my-monorepo/
├── go.work ← workspace file (repo root)
├── cmd/
│ ├── auth-gateway/
│ │ └── go.mod
│ └── iam-config-management/ ← you ran `go get` from here
│ └── go.mod
When you run go get inside cmd/iam-config-management, Go does not
stop at that folder. It checks the current directory for a go.work file, then the
parent directory, then the parent of that, and so on until it finds one or reaches the filesystem
root. In this repo it finds my-monorepo/go.work — so workspace mode turns
on automatically, even though your shell was inside a subfolder.
With workspace mode on, Go does not upgrade iam-config-management in isolation. It
tries to pick dependency versions that work for all modules listed in
go.work at the same time — as if the whole monorepo were one big project.
In this case, that is what went wrong:
iam-config-management wanted grpc v1.79.3, which needs the new split
genproto/googleapis/rpc module.auth-gateway (a different service you were not even touching) still depended on
the old monolithic genproto module.
Go could not satisfy both at once — the same import path existed in two different modules. So
your go get failed inside iam-config-management even though that
module alone was perfectly fine (which is exactly what GOWORK=off proved in
Step 2).
Go reported this as an ambiguous import for
google.golang.org/genproto/googleapis/rpc/status — that package path was claimed by
both the old monolithic genproto module and the newer split
genproto/googleapis/rpc module. With go.work active, Go had to pick one
answer for the entire workspace and could not.
Step 2 — isolate the blame. Still in cmd/iam-config-management,
turn off workspace mode for one command:
GOWORK=off go get google.golang.org/grpc@v1.79.3
This succeeded. So cmd/iam-config-management alone was fine; the conflict came
from another module in the workspace graph. GOWORK=off means:
"Ignore go.work for this command; resolve only the module I am standing in."
Step 3 — the fix. Still from cmd/iam-config-management, with
workspace mode back on (no GOWORK=off). Two commands, two different jobs:
3a — fix the root cause. Upgrade the stale genproto chain across the workspace:
go get google.golang.org/genproto@latest
This is the command that actually solved the problem. It moved
genproto, genproto/googleapis/api,
genproto/googleapis/rpc, protobuf, and related modules to versions compatible with
each other. The old monolithic genproto was replaced by the split modules, so the
ambiguous import went away.
3b — set the grpc version you wanted. Now the workspace graph was healthy enough to accept the grpc upgrade (or downgrade) you originally tried:
go get google.golang.org/grpc@v1.79.3
This command had failed in Step 1. After Step 3a, it succeeded — it updated
google.golang.org/grpc to v1.79.3 (and any grpc-related modules the resolver
needed) without hitting the genproto conflict again.
Debugging recipe: workspace go get fails → try
GOWORK=off go get ... in the module you care about. If that works, the bug is
in the workspace graph, not in your module alone.
go.work.sum
Workspace mode adds a second checksum file beside go.work. Same rule as
go.sum: it verifies downloads; it does not choose versions.
| File | Plain English | Decides versions? |
|---|---|---|
go.mod |
What this one module needs | Yes |
go.sum |
Checksums for this one module | No — verify only |
go.work |
Which modules belong to the workspace | No — list only |
go.work.sum |
Extra checksums the workspace needed that no single module's go.sum had |
No — verify only |
Go maintains go.work.sum automatically in workspace mode. Which file each command
touches:
| Command | Module-level (go.mod / go.sum) |
Workspace-level (go.work.sum) |
|---|---|---|
go mod tidy |
Yes — cleans the module you are standing in | No |
go get |
Yes — updates that module's go.mod |
Can add checksums if workspace resolution needs them |
go mod download |
Yes — reads that module's go.mod, verifies against its go.sum |
Can add checksums if workspace resolution needs them |
go build / go test / go run |
May update go.mod / go.sum when resolving imports |
Can add checksums if workspace resolution needs them |
go work sync |
Yes — rewrites every workspace module's go.mod |
Yes — may refresh go.work.sum |
go.work.sum only stores checksums that the workspace needs but that are not already
recorded in any individual module's go.sum. Do not edit it by hand.
Commit it to git. That way every developer and every CI run checks downloaded
modules against the same checksums. If you delete go.work.sum, Go will create a new
one the next time someone runs a workspace command — but until that happens, different machines
may verify dependencies slightly differently, so builds are less predictable across the team.
Do not assume every line in go.work.sum is a dependency you still
use. Same rule as go.sum: both files can keep checksum lines for modules Go
downloaded or checked in the past. Your project may have moved on, but the old line can stick
around — that is normal and harmless.
To clean up: run go mod tidy inside a module to fix that module's
go.mod and go.sum (this does not touch
go.work.sum). For workspace-wide alignment, run go work sync. Build
and test commands can refresh go.work.sum too. Even after all of that, stale
checksum lines in either sum file may remain — that does not mean those modules are active
dependencies today.
go work sync do?
Imagine three services asking for different grpc versions. The workspace resolver picks one
version that works for everyone. go work sync writes that shared choice back into
every module's go.mod so the files match what the workspace actually
uses.
Before sync:
cmd/auth-gateway → grpc v1.75.0cmd/user-management → grpc v1.75.0cmd/iam-config-management → grpc v1.79.3
Workspace resolves grpc to v1.79.3. After go work sync, the first two modules'
go.mod files are updated to v1.79.3 as well.
Does go get run go work sync for you? No. They are
separate commands:
go get — updates the go.mod of the module you are standing in
(the "main module" for that command). In workspace mode, Go still considers the whole
workspace when choosing versions, but go get only writes to
that one module's go.mod.go work sync — reads the workspace's combined build list and writes the
selected versions back into all modules listed in go.work.
So after a workspace go get, other services' go.mod files may still
show older versions until you run go work sync yourself. Run it when you want every
module's go.mod to match what the workspace resolver selected — for example after
bulk upgrades or before committing dependency changes across the monorepo.
go mod download
After git clone, you have source code — not dependencies. Just run
go build, go test, or go run as usual; the first command
fetches whatever is missing. You do not need go mod download first.
go mod download is the same kind of fetch, but upfront — handy in CI, on build
agents, or before going offline. When it runs, go.mod decides
what to download; go.sum (and go.work.sum in workspace
mode) checks whether the download is trustworthy.
Run go mod download inside cmd/iam-config-management and you get that module's dependencies only —
not the whole monorepo. There is no single "download everything in go.work" command.
To cover a workspace, either loop go mod download in each cmd/*
directory, or run go test ./... / go build ./... from the root and let
Go fetch along the way.
go mod why -m — who is pulling this in?The question this command answers: "Who is pulling this dependency into my dependency graph?"
Use it when:
go list -m all and you did not add it yourself;go mod why -m google.golang.org/genproto/googleapis/rpc
Example output:
# google.golang.org/genproto/googleapis/rpc
cmd/auth-gateway/generated/api/v1
google.golang.org/genproto/googleapis/rpc/status
Read it bottom-up as a chain:
cmd/auth-gateway/generated/api/v1 imports
google.golang.org/genproto/googleapis/rpc/statusgoogle.golang.org/genproto/googleapis/rpcSo auth-gateway's generated API code is the answer to "who is pulling this in?" — not grpc, not your hand-written handler, but that generated package.
When the module is not actually needed, you may see:
# google.golang.org/genproto
(main module does not need module google.golang.org/genproto)
Translation: nothing in your current build imports it. So why might you still see that module
name elsewhere? It can be a leftover checksum line in go.sum (module level) or
go.work.sum (workspace level) from an earlier go get or build — that
is normal and does not mean the module is active today. Run go mod tidy to clean up
the module's go.mod and go.sum; in workspace mode,
go work sync can refresh go.work.sum too. Even then, old checksum
lines in either file may stick around harmlessly.
go.mod = shopping list (versions). go.sum = integrity stamps (not
versions).go.work = which modules develop together. go.work.sum = extra
integrity stamps for the workspace.go get anywhere in the repo.GOWORK=off = "ignore go.work for this one command."go work sync = write the workspace's chosen versions back into each
go.mod. Not run automatically by go get — run it yourself when
you want all modules aligned.go mod why -m = "Who is pulling this dependency into my dependency
graph?"git clone does not download Go modules — the first build, test, or run does.go.sum or go.work.sum.init() functionRuns automatically before main(); useful for one-off setup.
func init() {
fmt.Println("initialized")
}
:=var a int = 10
var b = 20 // type inferred
c := 30 // short declaration, only inside functions
const pi = 3.14
const MAX int = 100
var investmentAmount, years float64 = 1000, 10
var x, name = 1000, "ten" // multiple inferred types in one line
Every uninitialised variable gets a default zero value:
int -> 0float -> 0.0bool -> falsestring -> ""pointer, slice, map -> nilvar amount float64
fmt.Print("Investment Amount: ")
fmt.Scan(&amount) // & passes a pointer so Scan can write into 'amount'
fmt.Println("Hello", "World") // Hello World\n
fmt.Printf("%s is %d years old\n", n, a) // Alice is 25 years old
msg := fmt.Sprintf("%s is %d", n, a) // returns formatted string
In Go, whether an identifier is accessible from another package is determined entirely by its first letter:
This rule applies uniformly to everything: variables, constants, functions, types, struct fields, and methods.
package user
// Exported - accessible from other packages
type User struct {
Name string // exported field
Email string // exported field
age int // unexported field - only accessible within this package
}
// Exported constructor
func NewUser(name, email string, age int) User {
return User{Name: name, Email: email, age: age}
}
// Exported method
func (u User) Greet() string {
return "Hello, " + u.Name
}
// unexported helper - only usable within this package
func validate(email string) bool {
return len(email) > 0
}
package main
import "myapp/user"
func main() {
u := user.NewUser("Alice", "alice@example.com", 30)
fmt.Println(u.Name) // OK - exported field
fmt.Println(u.Greet()) // OK - exported method
// fmt.Println(u.age) // ERROR: u.age is unexported (cannot refer to unexported field)
}
Convention: constructors follow the pattern NewTypeName
(exported) or newTypeName (package-private). Keep fields unexported and
expose only what callers genuinely need — this lets you change the internal representation
later without breaking any code outside the package.
Go has only for - no while.
// classic for
for i := 0; i < 2; i++ { /* ... */ }
// "while"
for someBool { /* ... */ }
// infinite loop
for { /* ... */ }
break exits the loop.continue jumps to the next iteration.switchYou don't need break between cases - only one case ever runs. Note: break
inside a switch only exits the switch, not any enclosing loop. Use an
if if you need to break out of a loop from inside a switch.
Go avoids exceptions. Functions that can fail return an extra error value:
import "errors"
func getBalanceFromFile() (string, error) {
data, err := os.ReadFile("balance.txt")
if err != nil {
return "", errors.New("failed to find balance file")
}
return string(data), nil
}
rangefor i, v := range slice { /* ... */ }
for k, v := range mapVar { /* ... */ }
for i, r := range "hello" { /* r is a rune */ }
_Used to ignore values you don't need:
_, err := someFunc()
age := 32
agePtr := &age // type: *int
fmt.Println(*agePtr) // 32 - dereference to read
*agePtr = 20 // write through the pointer
Pointers avoid copying large structs when passing them as arguments.
| Feature | Array | Slice |
|---|---|---|
| Size | Fixed | Dynamic |
| Type | [5]int |
[]int |
arr := [3]int{1, 2, 3} // type [3]int
sli := []int{1, 2, 3} // type []int
When loading data from a database you can't know the count upfront, so you almost always use slices. Internally a slice is backed by an array; when you append past its capacity, Go allocates a new (larger) array and copies into it.
package main
import "fmt"
type User struct {
firstName string
lastName string
}
// Constructor convention: NewUser to be exportable, newUser for package-private.
func newUser(firstName, lastName string) User {
return User{firstName, lastName}
}
// "(u User)" is a value receiver - the method gets a COPY.
func (u User) outputUserDetails() {
fmt.Println(u.firstName, u.lastName)
}
// "(u *User)" is a pointer receiver - the method can MUTATE the original.
func (u *User) clearFirstName() { u.firstName = "" }
func main() {
appUser := User{firstName: "Ilman", lastName: "Iqbal"}
appUser.outputUserDetails() // prints original
appUser.clearFirstName() // mutates original
appUser.outputUserDetails() // first name is empty now
}
Structs have a fixed set of fields known at compile time. Maps have arbitrary keys you can add at runtime.
m := make(map[string]int)
m["a"] = 1
websites := map[string]string{
"Google": "https://google.com",
"AWS": "https://aws.com",
}
fmt.Println(websites["Google"])
websites["Linkedin"] = "https://linkedin.com"
delete(websites, "Google")
Note: built-in maps are not safe for concurrent use. For shared maps
between goroutines use sync.Mutex / sync.RWMutex or sync.Map.
int, int32, int64float32, float64string, boolbyte -> alias for uint8 (raw 8-bit)rune -> alias for int32 (a Unicode code point)byte vs rune (a critical difference)var word string = "Toñito"
for _, r := range word {
fmt.Printf("rune: %v, string: %s\n", r, string(r))
}
fmt.Println("len(word):", len(word)) // 7 - bytes
fmt.Println("len([]rune(word)):", len([]rune(word))) // 6 - runes
Use rune when working with Unicode text, counting characters, validating input, or
processing user-entered text.
import "unicode"
for _, r := range s {
if unicode.IsDigit(r) {
fmt.Println("Digit")
}
}
make vs newnew - allocates and returns a pointer to a zero value.make - initialises a slice, map, or channel (cannot be used for arrays).p := new(int) // *int, pointing at 0
s := make([]int, 5) // []int{0,0,0,0,0}
make([]int, 0, 10) // empty, capacity 10 - good when you'll append a known max
make([]int, 0) // empty, no capacity hint
make([]int, 10) // ten zero values - good when you'll assign by index
make(map[int]bool) // empty, no size hint
make(map[int]bool, 5) // empty, capacity HINT 5 (perf, not a fixed limit)
make(chan int) // unbuffered. Send blocks until a receiver is ready.
make(chan int, 2) // buffered, capacity 2. Send blocks only when buffer is full.
func add(a, b int) int { return a + b }
// multiple return values - extremely common with errors
func divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
defer, panic, and recoverdefer - executes the call when the surrounding function returns. LIFO order.panic - aborts the normal flow and starts unwinding.recover - regains control of a panicking goroutine; only works inside a deferred
function.func processRequest() {
defer fmt.Println("Cleanup: closing DB connection")
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovered from panic:", r)
}
}()
fmt.Println("Processing request...")
panic("database connection lost")
fmt.Println("Never executes")
}
func main() {
processRequest()
fmt.Println("Service continues running")
}
/* Output:
Processing request...
Recovered from panic: database connection lost
Cleanup: closing DB connection
Service continues running
*/
runtime.GOMAXPROCS(runtime.NumCPU()).Reuse a fixed pool of goroutines to process a stream of jobs - this caps CPU and memory usage.
func worker(id int, jobs <-chan int) {
for job := range jobs {
fmt.Printf("Worker %d processing job %d\n", id, job)
time.Sleep(time.Second)
}
}
func main() {
const numWorkers, numJobs = 3, 10
jobs := make(chan int)
for i := 1; i <= numWorkers; i++ {
go worker(i, jobs)
}
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs)
time.Sleep(5 * time.Second)
}
sync.Mutex
Only one goroutine can hold the lock at any time. Anyone else calling Lock() blocks until
the holder calls Unlock().
var mu sync.Mutex
counter := 0
go func() {
mu.Lock()
counter++
mu.Unlock()
}()
go func() {
mu.Lock()
fmt.Println(counter)
mu.Unlock()
}()
sync.RWMutex - reader/writer locks
RWMutex distinguishes between readers and writers. Multiple readers can hold the lock
at the same time; writers are exclusive.
rw.Lock() // write lock - blocks all reads & other writes
rw.Unlock()
rw.RLock() // read lock - allows other readers, blocks writers
rw.RUnlock()
With a plain Mutex, three goroutines reading the same map serialise:
Goroutine 1: LOCK -> READ -> UNLOCK
Goroutine 2: WAIT -> LOCK -> READ -> UNLOCK
Goroutine 3: WAIT -> LOCK -> READ -> UNLOCK
With RWMutex they all read concurrently:
Goroutine 1: RLOCK -> READ -> RUNLOCK
Goroutine 2: RLOCK -> READ -> RUNLOCK
Goroutine 3: RLOCK -> READ -> RUNLOCK
Rule of thumb: mostly reads -> RWMutex; many writes or simple code
-> plain Mutex. RLock() must only wrap reads -
writing while holding an RLock is a race condition.
Go's RWMutex gives writer priority: if a writer is waiting, new readers
are blocked until that writer finishes. This avoids writer starvation.
sync.OnceEnsures a piece of code runs exactly once - perfect for lazy initialisation.
var (
prom *ginprometheus.Prometheus
once sync.Once
)
func middleware() {
once.Do(func() {
prom = ginprometheus.NewPrometheus("gin")
})
}
sync.MapA concurrent map optimised for read-heavy workloads (does not use an RWMutex
internally).
var m sync.Map
m.Store("key", "value")
v, ok := m.Load("key")
sync/atomicAtomic operations complete in a single CPU instruction - no other goroutine can ever see a half-written value, and there's no lock to deadlock.
import "sync/atomic"
var counter int64
go func() { atomic.AddInt64(&counter, 1) }()
Don't use multiple atomic ops to keep two related variables in sync - they aren't a single instruction together.
time.Tickerticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for i := 1; i <= 10; i++ {
<-ticker.C
fmt.Println("Processing request", i, "at", time.Now())
}
For a rate limiter at N requests per second:
rate := 5
ticker := time.NewTicker(time.Second / time.Duration(rate))
context.Context
context.Context carries cancellation signals, timeouts, and request-scoped values.
It is heavily used in HTTP handlers, DB drivers, and any RPC client.
// 1. Timeout - auto-cancels after 2s
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // always release resources
go func(ctx context.Context) {
select {
case <-time.After(3 * time.Second):
fmt.Println("Work completed")
case <-ctx.Done():
fmt.Println("Context cancelled:", ctx.Err())
}
}(ctx)
// 2. Request-scoped values
ctx = context.WithValue(context.Background(), "userID", 12345)
v := ctx.Value("userID")
// 3. Manual cancellation
ctx2, cancel2 := context.WithCancel(context.Background())
cancel2() // triggers ctx2.Done()
net/http packagehttp.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})
http.ListenAndServe(":8080", nil)
ServeMux; Go 1.22+ supports method-prefixed patterns like
"GET /products").
Q: Does net/http need to be installed separately?
No. It is part of Go's standard library and is automatically available when Go is installed —
no go get required. Other common standard library packages you get for free:
fmt, os, strings, time,
encoding/json, log/slog, context,
errors, sync, crypto/rand.
A channel is a typed pipe used by goroutines to send and receive values safely. There are two categories: unbuffered and buffered.
make(chan T))Capacity 0 - sender and receiver must "shake hands" at the same instant.
ch := make(chan int)
go func() {
time.Sleep(time.Second)
fmt.Println("Received:", <-ch)
}()
ch <- 1 // blocks until the goroutine reads
select {
case ch <- 3:
fmt.Println("Sent")
default:
fmt.Println("Send would block (no receiver)")
}
make(chan T, n))ch := make(chan int, 1)
ch <- 1 // buffer was empty -> succeeds
go func() {
time.Sleep(time.Second)
fmt.Println("Received:", <-ch)
}()
ch <- 2 // blocks until the receiver frees a slot
chan struct{})A channel of empty struct carries no data, consumes zero bytes per send, and is the idiomatic way to notify completion or coordinate shutdowns.
done := make(chan struct{})
go func() {
time.Sleep(time.Second)
done <- struct{}{}
}()
<-done
fmt.Println("Main received signal")
sync.WaitGroup
Without it, main() can finish before its child goroutines run. WaitGroup is
a counter: tell it how many goroutines to wait for, mark each as done, and Wait() until
the counter reaches zero.
var wg sync.WaitGroup
wg.Add(3)
for i := 1; i <= 3; i++ {
go func(n int) {
defer wg.Done()
fmt.Println("Worker", n)
}(i)
}
wg.Wait()
A deadlock is "all goroutines are asleep, waiting for something that will never happen." The most
common cause is forgetting an Unlock. The fix is to always pair them with
defer:
mu.Lock()
defer mu.Unlock()
Two or more goroutines touch the same memory and at least one writes, without synchronisation. Go ships a built-in race detector:
go run -race main.go
If it prints WARNING: DATA RACE, you forgot a lock or a channel.
var mu sync.Mutex
var wg sync.WaitGroup
counter := 0
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println(counter) // 1000
counter := 0
ch := make(chan int)
var wg sync.WaitGroup
go func() { for v := range ch { counter += v } }()
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() { defer wg.Done(); ch <- 1 }()
}
wg.Wait(); close(ch)
fmt.Println(counter)
| Mutex | Channel |
|---|---|
| Protects shared memory | Avoids shared memory |
| Faster for simple cases | Safer & clearer for workflows |
| Easy to misuse (deadlocks) | Can block if misused |
| Lower overhead | More expressive |
Go's idiomatic DI is plain constructor injection - no framework required:
type Service struct { repo Repo }
func NewService(r Repo) *Service { return &Service{repo: r} }
(In Part 2 we'll graduate to uber/fx for larger graphs.)
pprof.sync.Pool.context.Context everywhere.
Next Step: That covers the language fundamentals! To see how to apply these primitives in a real-world, production-shaped microservice using Hexagonal Architecture (Ports and Adapters), CQRS, and uber/fx for dependency injection, check out the companion article: Building a Microservice with Hexagonal Architecture, CQRS and uber/fx.