Questions and answers from learning Go syntax. These cover tricky concepts that aren’t immediately obvious from reading the guide.
Table of Contents
- Methods and Receivers
- Pointers:
*and& - Error Creation
- Slices and Maps Initialization
ifwith Init Statementdeferpanicandrecover- Goroutines
- Channels
- Mutex
sync.Once- Worker Pool Pattern
- Buffered vs Unbuffered Channels
1. Methods and Receivers
How do methods actually work?
A method is just a regular function where Go automatically passes the “caller” as the first parameter:
// This method:func (p Person) FullInfo() string { return fmt.Sprintf("%s (age %d)", p.Name, p.Age)}
// Is essentially the same as this standalone function:func FullInfo(p Person) string { return fmt.Sprintf("%s (age %d)", p.Name, p.Age)}The difference is how you call it:
alice := Person{Name: "Alice", Age: 30}
alice.FullInfo() // method syntax — Go passes alice as `p`FullInfo(alice) // standalone function — you pass alice yourselfThe (p Person) part between func and the method name “attaches” the function to the Person type. After that, every Person value gets .FullInfo() available on it.
Does defining a method add it to the type?
Yes. You can keep adding methods to build up the type’s capabilities:
func (p Person) FullInfo() string { ... }func (p Person) IsAdult() bool { ... }func (p Person) Greet(other string) string { ... }Now Person has three methods:
alice.FullInfo() // "Alice (age 30)"alice.IsAdult() // truealice.Greet("Bob") // "Hi Bob, I'm Alice!"This is how Go builds up types without classes — define a struct, then attach behavior one method at a time.
Value receiver vs pointer receiver
The difference: a value receiver gets a copy, a pointer receiver gets the original.
// Value receiver — works on a copyfunc (p Person) HaveBirthday() { p.Age++ // changes the COPY only}
// Pointer receiver — works on the originalfunc (p *Person) HaveBirthdayForReal() { p.Age++ // changes the ORIGINAL}alice := Person{Name: "Alice", Age: 30}
alice.HaveBirthday()fmt.Println(alice.Age) // still 30 — the copy was modified, not alice
alice.HaveBirthdayForReal()fmt.Println(alice.Age) // 31 — alice herself was modifiedWhere does the modified copy go?
Nowhere. It’s gone.
- Go creates a copy of
alice→p = Person{Name: "Alice", Age: 30} - Inside the method,
p.Age++→ the copy now hasAge: 31 - The method returns → the copy is discarded
Nobody holds a reference to that copy. The Age: 31 value simply disappears. That’s why value receivers are useless for modification.
2. Pointers: * and &
Why do * and & appear together in constructor functions?
func NewBankAccount(owner string, initialDeposit float64) *BankAccount { return &BankAccount{ Owner: owner, Balance: initialDeposit, Active: true, }}They do two different jobs:
*BankAccount— in the return type, means “I’m going to return a pointer to a BankAccount”&BankAccount{...}— means “create a BankAccount and give me its address”
Think of it in two steps:
// Step 1: create a BankAccount value (lives somewhere in memory)BankAccount{Owner: "Alice", Balance: 100, Active: true}
// Step 2: & takes its address (like writing down where it lives)&BankAccount{Owner: "Alice", Balance: 100, Active: true}Analogy:
*BankAccount → "a piece of paper with a house's address on it"&BankAccount{...} → "build a house and write down its address on paper"You can also write it in separate steps:
func NewBankAccount(owner string, initialDeposit float64) *BankAccount { account := BankAccount{ // create the value Owner: owner, Balance: initialDeposit, Active: true, } return &account // return its address}3. Error Creation
Is errors.New() the way to create a new error type?
No — there are two different things:
errors.New() — creates a simple error value (not a new type):
var ErrNotFound = errors.New("not found")This just creates a value that satisfies the error interface. It can only carry a fixed string message.
A custom struct — creates an actual new error type:
type ValidationError struct { Field string Message string}
func (e *ValidationError) Error() string { return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message)}This is a real new type. It satisfies the error interface because it has an Error() string method. The advantage: it can carry extra data that callers can inspect.
| Approach | What it gives you |
|---|---|
errors.New("msg") | A simple error with a fixed message |
fmt.Errorf("msg %d", val) | A simple error with formatted message |
Custom struct + Error() method | A rich error carrying extra data |
4. Slices and Maps Initialization
Why use make to create slices and maps?
Slices — both literal and make work:
nums := []int{1, 2, 3} // literal — when you know the valuesnums := make([]int, 0, 100) // make — when you'll fill it laterMaps — you MUST use make or a literal:
ages := map[string]int{"Alice": 30} // literal — worksages := make(map[string]int) // make — works
var ages map[string]intages["Alice"] = 30 // PANIC! writing to a nil mapvar ages map[string]int gives you a nil map. A nil map can be read (returns zero values) but panics if you write to it. make allocates the internal hash table so writes are safe.
| Situation | Use |
|---|---|
| You know the values upfront | Literal: []int{1, 2, 3} |
| You’ll fill it later, know the size | make([]int, 0, size) |
| You’ll fill it later, unknown size | make([]int, 0) or make(map[string]int) |
| Empty map you’ll write to | Must use make or map[string]int{} |
5. if with Init Statement
What does if err := doSomething(); err != nil mean?
It’s two lines compressed into one. These are equivalent:
Long form:
err := doSomething() // execute function, get errif err != nil { // check err fmt.Println("Error:", err) return}// err still exists hereShort form:
if err := doSomething(); err != nil { fmt.Println("Error:", err) return}// err does NOT exist here — scoped to the if blockThe semicolon ; splits it into two parts:
if err := doSomething(); err != nil { ^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^ init statement conditionThe advantage: err only exists inside the if block, so you can reuse the name:
if err := step1(); err != nil { return err}// err is gone
if err := step2(); err != nil { // no conflict return err}6. defer
What is defer?
defer means “run this later — when the function exits.”
func example() { fmt.Println("first") defer fmt.Println("deferred") fmt.Println("second")}
// Output:// first// second// deferred ← runs last, right before the function returnsWhy is it useful?
It guarantees cleanup happens no matter how the function exits:
file, err := os.Open("data.txt")if err != nil { return err}defer file.Close() // guaranteed — runs no matter what happens belowWithout defer, you’d have to call file.Close() before every return — easy to forget.
What is a resource leak?
If you forget to close a file/connection, it stays open and occupies system resources. If this happens repeatedly (e.g., in a loop), you eventually run out and the program crashes with “too many open files.”
How to fix: Find where the resource is opened but never closed, and add defer Close().
How to prevent: Every time you see Open, Lock, Dial, or Acquire — the very next line should be defer Close/Unlock/Release.
7. panic and recover
What is panic?
panic = your program screams “something is terribly wrong!” and crashes.
func main() { fmt.Println("before panic") panic("everything is broken") fmt.Println("after panic") // NEVER runs}
// Output:// before panic// panic: everything is broken// (program crashes)When panic is called, the function immediately stops. No code after it runs. Use it only for bugs that should never happen — not for normal errors.
What is recover?
recover = catch the panic before it crashes the program.
func safeCall() { defer func() { r := recover() // catch the panic if r != nil { fmt.Println("caught a panic:", r) } }()
panic("oh no!") // would normally crash...but recover catches it}Step by step:
panic("oh no!")fires — function starts to exit- Before exiting, Go runs
deferfunctions - Inside the deferred function,
recover()catches the panic value - Because the panic was caught, the program doesn’t crash
Important: recover() only works inside a defer function.
Analogy:
panic = pulling the fire alarm (everything stops)recover = a firefighter catching it and saying "false alarm, carry on"8. Goroutines
What is a goroutine?
A goroutine is like a thread — it means “do something at the same time.” (Technically lighter than OS threads, but mentally the same.)
Without goroutines — sequential (one at a time):
sayHello("Alice") // wait until this finishes...sayHello("Bob") // then run thisWith goroutines — concurrent (at the same time):
go sayHello("Alice") // start this, DON'T wait for itgo sayHello("Bob") // start this too, DON'T waitgo means: “start this function in the background, and immediately move to the next line.”
Why do we need time.Sleep or WaitGroup?
When main returns, the program exits and kills all goroutines — even if they haven’t finished. You need something to keep main alive:
time.Sleep— crude, you’re guessing how long to waitsync.WaitGroup— proper way, waits until all goroutines finish
var wg sync.WaitGroup
wg.Add(1) // "1 goroutine starting"go func() { defer wg.Done() // "this goroutine finished" sayHello("Alice")}()
wg.Wait() // blocks until counter = 09. Channels
What is a channel?
A channel is a pipe between goroutines. One side puts something in, the other side takes it out.
ch := make(chan string) // create a pipe that carries strings
go func() { ch <- "hello" // put INTO the pipe}()
msg := <-ch // take OUT of the pipe (waits until something arrives)fmt.Println(msg) // "hello"Why not just use a variable?
Because goroutines run at the same time. Without a channel, you don’t know when the other goroutine is done writing:
// WITHOUT channel — broken:var msg stringgo func() { msg = "hello" // when does this finish? who knows!}()fmt.Println(msg) // might print "" — goroutine hasn't finished yet
// WITH channel — safe:ch := make(chan string)go func() { ch <- "hello"}()msg := <-ch // WAITS until the goroutine sendsfmt.Println(msg) // always prints "hello"Unbuffered vs buffered?
Unbuffered = hand-to-hand delivery. Sender waits until receiver is ready.
ch := make(chan string) // no buffer — both sides must be presentBuffered = a mailbox with slots. Sender drops it off and leaves (until mailbox is full).
ch := make(chan string, 3) // 3 slots — sender can drop off 3 without waitingWhat is select with timeout?
“Wait for a result, but give up after X seconds”:
select {case result := <-ch: fmt.Println("Got result:", result)case <-time.After(3 * time.Second): fmt.Println("Timed out!")}Whichever happens first wins. If the result arrives within 3 seconds, first case runs. If 3 seconds pass with no result, second case runs.
10. Mutex
What happens when two goroutines access shared data at the same time?
Without mutex — broken:
Goroutine A: reads count (0)Goroutine B: reads count (0) ← both read 0Goroutine A: writes count = 1Goroutine B: writes count = 1 ← should be 2, but it's 1! Lost an increment.With mutex — safe:
Goroutine A: Lock() ✓ → reads count, increments, writes → Unlock()Goroutine B: Lock() → WAITS... → A unlocks → B gets lock ✓ → reads, increments, writesOnly one goroutine holds the lock at a time. The other must wait.
sync.Mutex vs sync.RWMutex?
| Situation | Mutex | RWMutex |
|---|---|---|
| Multiple readers at once | No — one at a time | Yes — all read simultaneously |
| Reader + writer at once | No | No — one must wait |
| Multiple writers at once | No | No |
RWMutex is an optimization: reading doesn’t corrupt data, so there’s no reason to block readers from each other. Only writing is dangerous.
How does Go know which goroutine gets the lock first?
Whoever calls Lock()/RLock() first wins. It’s essentially random. But that’s fine — the mutex guarantees it doesn’t matter which order they run. Both orders are safe.
11. sync.Once
What does sync.Once do?
It ensures a function runs exactly once, even if called from multiple goroutines simultaneously.
var once sync.Once
func GetDatabase() *Database { once.Do(func() { instance = connectToDatabase() // runs only the first time }) return instance}Goroutine 1: calls GetDatabase() → once.Do runs connectToDatabase() ✓Goroutine 2: calls GetDatabase() → once.Do sees "already ran" → skipsGoroutine 3: calls GetDatabase() → once.Do sees "already ran" → skipsWithout sync.Once — race condition:
if instance == nil { // two goroutines both check at the same time instance = connectToDatabase() // both connect — oops, connected twice!}Analogy: Opening a store. Ten employees arrive at the same time. Only one should unlock the door — the rest just walk in.
12. Worker Pool Pattern
Walkthrough
func main() { jobs := make(chan int, 100) // to-do list (buffered) results := make(chan int, 100) // finished pile (buffered)
// Start 3 workers for w := 0; w < 3; w++ { go worker(jobs, results) }
// Send 9 jobs for j := 0; j < 9; j++ { jobs <- j } close(jobs)
// Collect results for r := 0; r < 9; r++ { fmt.Println(<-results) }}
func worker(jobs <-chan int, results chan<- int) { for job := range jobs { results <- job * 2 }}Step by step:
- Create pipes —
jobsfor sending work to workers,resultsfor getting answers back - Start 3 workers — each runs in the background, waiting at the
jobspipe - Send 9 jobs — numbers 0-8 go into the buffer
close(jobs)— tells workers “no more jobs coming”- Workers grab jobs — each takes the next available item from the buffer, computes
job * 2, sends result - Collect results — main reads 9 values from
results
The workers share the jobs channel. Only one worker gets each job — it’s like taking an item off a shelf (once taken, it’s gone).
How does for job := range jobs work?
// This:for job := range jobs { // use job}
// Is the same as:for { job, ok := <-jobs // take from pipe if !ok { // pipe closed and empty? break // stop } // use job}The loop keeps grabbing jobs until the channel is closed and empty.
What are the results?
Values: 0, 2, 4, 6, 8, 10, 12, 14, 16 (each job × 2). But the order is unpredictable because 3 workers run in parallel — whoever finishes first sends their result first.
13. Buffered vs Unbuffered Channels
”Both seem the same — workers take items one by one either way?”
From the worker’s perspective, yes — no difference. The difference is on the sender’s side:
Unbuffered — sender must wait for a worker to take each item:
Main sends 0 → waits... → worker takes it ✓Main sends 1 → waits... → worker takes it ✓Main sends 2 → waits... → worker takes it ✓(9 waits total)Buffered(100) — sender dumps everything instantly:
Main sends 0,1,2,3,4,5,6,7,8 → all go in buffer immediately, main moves onWorkers grab from buffer at their own paceAnalogy:
Unbuffered = hand-delivering tasks to workers one by one, waiting each timeBuffered = putting all tasks on a table and walking away — workers help themselvesIs buffered always better?
No. Unbuffered has one advantage: backpressure.
Buffered(1000): main sends 1000 jobs instantly. If workers are slow → 1000 items sit in memory → high memory usage.
Unbuffered: main is FORCED to slow down to workers' speed. Only sends the next job when a worker is free → low memory usage.| Use | When |
|---|---|
| Unbuffered | You want sender to slow down if receiver is behind (backpressure) |
| Unbuffered | You need confirmation the other side received it |
| Buffered | Sender and receiver work at different speeds |
| Buffered | You know the max items and don’t want sender to wait |
Some information may be outdated






