Error Handling
Python uses exceptions — errors fly up the call stack until a try/except catches them. Go uses explicit error return values. There is no try, no except, no throw. Errors are values you check at every call site.
try:
data = read_file(path)
config = parse(data)
validate(config)
except FileNotFoundError:
log.error("file missing")
except ValueError as e:
log.error(f"bad config: {e}")
data, err := readFile(path)
if err != nil {
return fmt.Errorf("reading: %w", err)
}
config, err := parse(data)
if err != nil {
return fmt.Errorf("parsing: %w", err)
}
if err := validate(config); err != nil {
return fmt.Errorf("validating: %w", err)
}
Creating Errors
import "errors"
// Simple error
return errors.New("something broke")
// Formatted error
return fmt.Errorf("user %s not found", id)
// Wrapping (preserves the chain for errors.Is/As)
return fmt.Errorf("loading config: %w", err) // %w wraps
Custom Error Types
type NotFoundError struct {
Resource string
ID string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s %s not found", e.Resource, e.ID)
}
// Check error type (like isinstance in except blocks)
var nfe *NotFoundError
if errors.As(err, &nfe) {
fmt.Println("missing:", nfe.Resource, nfe.ID)
}
// Check sentinel error (like catching a specific exception)
if errors.Is(err, os.ErrNotExist) {
fmt.Println("file doesn't exist")
}
Panic & Recover
Go does have panic — roughly equivalent to raising an unrecoverable exception. It's reserved for programmer errors (index out of bounds, nil dereference), not for expected failures. recover catches panics but should be used sparingly — it's the exception to the rule.
// Panic — for bugs, not business logic
func mustParseURL(raw string) *url.URL {
u, err := url.Parse(raw)
if err != nil {
panic(fmt.Sprintf("invalid URL: %s", raw))
}
return u
}
// Recover — catches panic in the same goroutine
defer func() {
if r := recover(); r != nil {
log.Printf("recovered: %v", r)
}
}()
If you find yourself using panic/recover like try/except, stop. Return errors. Panic is for situations where the program cannot continue — corrupted state, impossible invariants. Library functions named MustXxx panic on error by convention.
Concurrency
Python has the GIL, asyncio, threading, and multiprocessing — four different concurrency models, each with tradeoffs. Go has goroutines and channels. One model. It works.
Goroutines
A goroutine is a lightweight thread managed by the Go runtime. They cost ~2KB of stack (vs ~8MB for OS threads). You can spawn millions.
import asyncio
async def fetch(url):
# ... async HTTP call
return data
async def main():
results = await asyncio.gather(
fetch("url1"),
fetch("url2"),
)
func main() {
var wg sync.WaitGroup
urls := []string{"url1", "url2"}
for _, url := range urls {
wg.Add(1)
go func() {
defer wg.Done()
fetch(url)
}()
}
wg.Wait()
}
Channels
Channels are typed conduits for sending values between goroutines. They are the primary synchronization mechanism — Go's alternative to shared memory with locks.
// Unbuffered channel — send blocks until receiver is ready
ch := make(chan string)
go func() {
ch <- "hello" // send
}()
msg := <-ch // receive (blocks until value available)
// Buffered channel — send blocks only when buffer is full
ch := make(chan int, 100)
// Range over channel — reads until closed
for msg := range ch {
process(msg)
}
Select
select is a switch for channels — blocks until one of the channel operations can proceed.
select {
case msg := <-dataCh:
process(msg)
case err := <-errCh:
handleError(err)
case <-time.After(5 * time.Second):
fmt.Println("timeout")
case <-ctx.Done():
fmt.Println("cancelled")
return
}
Common Patterns
// Worker pool
func workerPool(jobs <-chan Job, results chan<- Result, workers int) {
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
results <- process(job)
}
}()
}
wg.Wait()
close(results)
}
This is Go's concurrency motto. Instead of protecting shared state with sync.Mutex (which works, and is sometimes appropriate), prefer sending data through channels. The channel ensures only one goroutine accesses the data at a time. When you do need a mutex, sync.Mutex and sync.RWMutex work as you'd expect.