Go Loops: Practical Patterns
Go keeps loops relatively small.
You do not get a large menu of loop keywords. In practice, most code is built around for, range, break, and continue. That is enough for a lot of real work, but the value is not in the syntax itself. The value is in how clearly the loop expresses retries, scans, thresholds, batching, and stop conditions.
This article looks at those patterns using practical examples.
The basic for loop is still useful
The classic three-part for loop still earns its place when the loop count is part of the logic.
A good example is retry logic:
func retryWebhook(maxAttempts int) error {
for attempt := 1; attempt <= maxAttempts; attempt++ {
err := sendWebhook()
if err == nil {
return nil
}
fmt.Println("webhook failed on attempt", attempt)
}
return errors.New("webhook failed after all retries")
}
This is the kind of loop where the counter belongs in the header. The number of attempts is part of the behavior, so keeping it there makes the control flow easier to read.
You see this shape a lot in real code:
- retries
- pagination limits
- fixed-size scans
- rate-limited attempts
- backoff calculations
The syntax is not special. The value is that the loop header tells you what changes and when the repetition is supposed to stop.
Go does not need a separate while
Go does not have a dedicated while keyword. A for with only a condition plays that role.
retryCount := 0
for retryCount < 5 {
fmt.Println("retrying...")
retryCount++
}
This ends up being useful in a lot of normal code:
- retry until limit
- read until EOF
- poll until ready
- keep paging until there is no next page
- keep consuming until the queue is empty
For example, fetching paginated order data:
func syncOrders() error {
page := 1
for page <= 20 {
orders, hasNext, err := fetchOrders(page)
if err != nil {
return err
}
processOrders(orders)
if !hasNext {
break
}
page++
}
return nil
}
This is a normal condition-driven loop. There is no real need for a separate keyword just to express it.
Infinite loops are normal in long-running processes
Go also allows a bare loop:
for {
// ...
}
That is an intentional infinite loop unless something inside it stops the flow.
This is useful in worker-style code:
func runWorker() {
for {
job, err := dequeueJob()
if err != nil {
fmt.Println("dequeue failed:", err)
time.Sleep(2 * time.Second)
continue
}
if err := processJob(job); err != nil {
fmt.Println("job failed:", err)
}
}
}
This pattern shows up in:
- workers
- queue consumers
- background daemons
- long-running pollers
- stream processors
The important part is not that the loop is infinite. The important part is whether the loop has a readable control story. If the loop is intentionally open-ended, the code still needs clear rules for retry, sleep, exit, cancellation, or failure handling.
break is often the right exit
Not every loop can express its stop condition cleanly in the header.
Sometimes the stop decision depends on values that are only known after the loop starts doing work. That is where break is the honest tool.
For example, adding orders to a payout batch until the batch total reaches its limit:
type Order struct {
Amount int
}
func maxOrdersForBatch(orders []Order, maxBatchAmount int) int {
batchTotal := 0
count := 0
for _, order := range orders {
if batchTotal+order.Amount > maxBatchAmount {
break
}
batchTotal += order.Amount
count++
}
return count
}
The reason for stopping belongs inside the loop because it depends on both a running total and the next item being evaluated. Forcing that into the loop header would not make the code better.
This is a good general rule: sometimes the stop condition is part of the work, not part of the loop header.
continue helps keep loops flatter
continue is one of the simplest tools in the language, but it is useful when a loop filters records or skips invalid work.
For example, importing subscribers from a CSV-like input:
func importSubscribers(rows []ImportRow) {
for _, row := range rows {
if row.Email == "" {
continue
}
if !row.MarketingConsent {
continue
}
if err := saveSubscriber(row); err != nil {
fmt.Println("failed to save subscriber:", err)
continue
}
}
}
This is usually easier to read than wrapping the body in nested if statements.
You see this kind of loop in:
- imports
- event consumers
- product feeds
- log processors
- validation passes
- notification targeting
continue works well when the current item no longer deserves more work and the next iteration should start immediately.
Modulo becomes useful in batching
The modulo operator is not a loop feature by itself, but loops are where it often becomes practical.
A very common case is flushing work every N items:
func importProducts(products []Product) error {
batch := make([]Product, 0, 100)
for i, product := range products {
batch = append(batch, product)
if (i+1)%100 == 0 {
if err := writeBatch(batch); err != nil {
return err
}
batch = batch[:0]
}
}
if len(batch) > 0 {
if err := writeBatch(batch); err != nil {
return err
}
}
return nil
}
This kind of logic shows up in:
- batch inserts
- chunked exports
- log flushing
- rate-limited sends
- periodic checkpointing
- grouped API calls
Modulo looks boring until the code needs to do something every 50, 100, or 1000 iterations. Then it becomes one of the more practical tools around loops.
range is where a lot of real Go code lives
In many applications, the most common loop is not a counted loop at all. It is range.
For example:
func findFailedPayments(payments []Payment) []Payment {
var failed []Payment
for _, payment := range payments {
if payment.Status != "failed" {
continue
}
failed = append(failed, payment)
}
return failed
}
This is a normal loop shape in Go:
- walk through a collection
- skip what does not matter
- collect or process what does
That is why range shows up so often in:
- filtering
- scans
- transformations
- validations
- reporting
- aggregation
If the loop is really about the elements, range is usually the clearest tool.
Sometimes you need the index, sometimes you do not
One useful thing about range is that it makes this choice obvious.
If you need the index, use it:
for i, shipment := range shipments {
fmt.Println(i, shipment.ID)
}
If you do not, ignore it:
for _, shipment := range shipments {
fmt.Println(shipment.ID)
}
That seems small, but it keeps intent cleaner. Index-heavy loops often get harder to read when the index is not actually part of the logic.
If the code only cares about the values, it should say so.
Loops over text are not always loops over bytes
This matters more than people think.
When you use range on a string in Go, you iterate over runes, not just raw bytes. That matters in real code when you deal with:
- Unicode usernames
- SMS previews
- truncation logic
- validation
- search indexing
- internationalized text
For example:
func countNonSpaceRunes(s string) int {
count := 0
for _, r := range s {
if unicode.IsSpace(r) {
continue
}
count++
}
return count
}
The loop itself is simple. The representation of what you are iterating over is the part that matters.
This is one of those places where loops stay small, but the data model does not.
Loop shape should follow the problem
This is the useful rule.
A retry loop is not the same thing as a collection scan. A worker loop is not the same thing as a threshold loop. A batch importer is not the same thing as a paginated API fetch.
So the better question is not which loop form is the most Go-like. The better question is: which loop form makes the control flow easiest to see?
A few examples:
Use a counted loop when the count matters:
for attempt := 1; attempt <= maxRetries; attempt++ {
// ...
}
Use a condition-only loop when the condition drives the repetition:
for hasMorePages {
// ...
}
Use for {} when the loop is intentionally open-ended:
for {
// worker / consumer / poller
}
Use range when the collection is the obvious driver:
for _, event := range events {
// ...
}
That is usually a better guide than aesthetics.
Loops are often where error handling and control flow meet
A lot of real loop code is not just repetition. It is where iteration, filtering, retries, and error handling all meet.
For example:
func publishBatch(events []Event) error {
for _, event := range events {
if event.Topic == "" {
continue
}
if err := publish(event); err != nil {
return fmt.Errorf("publish event %s: %w", event.ID, err)
}
}
return nil
}
That loop is doing several things at once:
- skipping invalid data
- processing valid items
- stopping on a real failure
- preserving error context
This is why loop style matters. The syntax is small, but the loop often ends up being the place where policy becomes visible.
Real-world loops are often simpler than people expect
A lot of useful loops are not algorithmically fancy.
They do things like:
- retry API calls
- stop when the budget runs out
- skip invalid rows
- batch every 100 records
- find the first matching item
- keep polling until a status changes
- sum until a threshold is reached
For example, finding the first active subscription:
func firstActiveSubscription(subscriptions []Subscription) *Subscription {
for i := range subscriptions {
if subscriptions[i].Status == "active" {
return &subscriptions[i]
}
}
return nil
}
Or checking whether incoming shipments are enough to fulfill an order:
func canFulfill(orderQty int, shipments []Shipment) bool {
total := 0
for _, shipment := range shipments {
total += shipment.Quantity
if total >= orderQty {
return true
}
}
return false
}
These are not flashy loops. That is fine. Most good loops are not flashy.
The useful question is whether the code makes repetition, exit conditions, skip logic, and failure behavior easy to follow.
So what makes loops in Go feel like Go?
If you zoom out, a few patterns show up repeatedly:
- one keyword does most of the work
- loop shape is chosen by the problem
breakandcontinuekeep control flow localrangehandles most collection walking cleanly- condition-only loops replace a separate
while - infinite loops are normal in workers and pollers, but still need an exit story
- modulo becomes practical in batching and periodic behavior
- loops often become the place where iteration and error handling meet
None of this is especially flashy.
That is probably why loops in Go feel straightforward at first and more deliberate later. The syntax stays small, but the control-flow choices still matter once the code starts doing real work.
Final thoughts
Go does not try to impress anyone with loop syntax.
It gives you one main loop keyword and expects you to use it in a few different ways depending on the problem. That can feel limited at first. In practice, it often keeps looping logic more consistent than people expect.
The quality of loop code in Go is usually not about the syntax anyway. It is about whether the code makes repetition, stop conditions, skips, and failure paths easy to see.
And in real systems, that is usually what matters.