Go’s Single Loop, the Compiler That Forces Cleanup, and Why Both Make Production Code Safer
Go’s one loop keyword handles everything here’s how it works in production code
The Situation
You’re building a health checker. It needs to do three things: loop over a list of endpoints, decide what to do based on the HTTP response code, and clean up connections regardless of what happens along the way.
Three problems. Three control flow tools. By the end of this article, you’ll have the complete control logic for that tool and you’ll understand why Go’s design choices here aren’t limitations but deliberate reductions in the kind of cognitive load that causes production incidents.
Hi — this is Pushpit from CloudOdyssey . I write about Cloud, DevOps, Systems Design deep dives and community update around it. If you have not subscribed yet, you can subscribe here.
The if Statement With a Pattern You’ll Use Every Day
The if statement in Go looks familiar until you see the idiom that shows up in almost every real Go program:
// err is scoped to this if block only — it doesn't leak into the function
if err := checkEndpoint(url); err != nil {
log.Printf("endpoint %s failed: %v", url, err)
continue
}That first clause before the semicolon is an initialiser. It runs before the condition is evaluated, and any variable declared there lives only inside the if block.
Why this matters in practice: if you’re checking 50 endpoints in a loop, you don’t want 50 err variables floating around in your function scope. The scoped pattern keeps each error contained, which keeps the function readable and the compiler happy Go won’t let you declare a variable you don’t use.
You’ll see this pattern everywhere Go handles fallible operations. It’s not stylistic preference, it’s how the language guides you toward writing code where errors are handled at the point they occur.
The for Loop One Keyword, Four Behaviours
Go has exactly one looping keyword: for. No while, no do...while, no foreach. This is a deliberate design choice (go.dev/doc/faq), and once you’ve used it for a while, you stop missing the others.
Classic for retry logic with a counter:
// Try the endpoint up to 3 times before giving up
for attempt := 1; attempt <= 3; attempt++ {
resp, err := http.Get(url)
if err == nil && resp.StatusCode == 200 {
break // success, stop retrying
}
log.Printf("attempt %d failed, retrying...", attempt)
time.Sleep(time.Duration(attempt) * time.Second) // backoff increases each try
}This is standard: initialiser, condition, post-statement.
While-style for polling until a service is ready:
// Keep checking until the service is healthy or we've waited too long
for !isHealthy(url) {
log.Println("service not ready, waiting...")
time.Sleep(2 * time.Second)
}No initialiser, no post-statement. Identical to a while loop in any other language. Go just doesn’t give it a separate name.
Infinite for with break reading a stream of events:
// This is how most Go servers are structured at their core
for {
event, err := readNextEvent(conn)
if err != nil {
break // clean exit on error or shutdown signal
}
process(event)
}The infinite loop with an explicit break is a pattern you’ll see in every Go network server. It keeps the happy path clean and puts the exit condition exactly where it belongs.
Range iterating your endpoint list:
endpoints := []string{"api.internal", "cache.internal", "db.internal"}
// Both index and value
for i, endpoint := range endpoints {
fmt.Printf("checking endpoint %d: %s\n", i, endpoint)
}
// Value only — use _ to tell the compiler you're intentionally ignoring the index
for _, endpoint := range endpoints {
check(endpoint)
}
// Key only — common when ranging over a map to get unique keys
statusMap := map[string]int{"api": 200, "cache": 429}
for service := range statusMap {
fmt.Println(service) // just the keys
}Range works on slices, arrays, maps, strings, and channels. The two-variable form always gives you position then value. Drop the first with _ when you don’t need it the compiler will complain if you declare it and don’t use it.
One keyword. Four behaviours. Everything you need, nothing you don’t.
switch Without the Fallthrough Trap
Your health checker receives an HTTP status code. You need to classify it. Here’s the Go way:
func classifyStatus(code int) string {
switch code {
case 200:
return "healthy"
case 429:
return "rate limited" // slow down, don't abandon
case 500, 502, 503:
return "degraded" // multiple codes, one case
default:
return "unknown"
}
}In C, every case falls through to the next unless you add a break. Forgetting that break is a well-documented class of silent, hard-to-find bugs. Go reversed the default: cases do not fall through unless you explicitly write fallthrough. This makes the code above safe by default.
Go also has the expression-free switch, which is idiomatic for complex conditionals:
// No variable after switch — each case is its own boolean expression
switch {
case latencyMs > 1000:
log.Println("endpoint too slow")
case latencyMs > 500:
log.Println("endpoint degraded")
default:
log.Println("endpoint acceptable")
}This reads exactly like a chain of if/else but keeps the visual alignment that makes the conditions easy to scan. You’ll see this in a lot of Go infrastructure code.
defer The Cleanup Guarantee
Here’s the bug that defer eliminates. You open an HTTP connection to check an endpoint. You have three possible return paths: the request fails, the status is wrong, or everything is fine. In all three cases, you must close the response body or you’ll leak the connection.
Without defer, you close it manually in each branch. One day, someone adds a fourth return path and forgets. The connection leaks. The service slows down under load. You spend an afternoon in production debugging it.
With defer:
func checkEndpoint(url string) (string, error) {
resp, err := http.Get(url)
if err != nil {
return "", err // defer hasn't run yet — nothing to close
}
defer resp.Body.Close() // guaranteed to run when this function exits, no matter what
if resp.StatusCode != 200 {
return "unhealthy", nil // defer runs here
}
return "healthy", nil // and here
}defer schedules a function call to run when the surrounding function returns. It doesn’t matter how the function exits. The deferred call runs. This is not optional it’s how Go services avoid resource leaks.
When you defer multiple things, they run in LIFO order (last in, first out):
conn, _ := net.Dial("tcp", host)
defer conn.Close() // runs second
f, _ := os.Open(logFile)
defer f.Close() // runs first — last deferred, first to executeFile closes before the connection. Which is the right order you’d want to flush your log before dropping the connection that might be writing to it.
In the Cloud Wild
The Kubernetes Go client (
k8s.io/client-go/util/retry) implements retry logic using exactly the patterns in this article: aforloop, aswitchon error type to decide whether to retry or give up, anddeferto clean up resources regardless of outcome. When you runkubectl applyand it retries on a conflict error that’s this code. The retry backoff is configurable, the error classification is a switch, and the cleanup is deferred. Production Go control flow at scale, all built from the same three keywords.
Putting It Together
Your health checker’s core loop now looks like this:
for _, endpoint := range endpoints {
status, err := checkEndpoint(endpoint) // defer inside handles cleanup
if err != nil {
log.Printf("error: %v", err)
continue
}
switch status {
case "healthy":
metrics.Inc("healthy")
case "degraded":
metrics.Inc("degraded")
alert(endpoint)
default:
metrics.Inc("unknown")
}
}Clean. Each concern in its place. The cleanup is guaranteed, the error is scoped, the loop does exactly one thing.
Feature What it prevents Scoped if initialiser Error variables leaking into outer scope Single for keyword Learning three loop syntaxes for the same behaviour No fallthrough by default Silent C-style case bleed-through bugs defer Resource leaks on every non-happy-path return
What’s Next
The next article is Your First Go Program: A Cloud Health Checker


